Skip to content

Commit 26a802e

Browse files
clucraftclaude
andcommitted
Add per-product pause/resume checking feature
Users can now pause and resume price checking for individual products or in bulk via the Actions menu. Backend: - Added checking_paused column to products table - Scheduler skips products with checking_paused=true - Added POST /products/bulk/pause endpoint for bulk pause/resume Frontend: - Added Pause Checking and Resume Checking to bulk Actions menu - Added filter dropdown (All/Active/Paused) next to sort controls - Paused products show greyed out with pause icon and "Paused" label - Progress bar hidden when paused Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1f66823 commit 26a802e

File tree

6 files changed

+176
-6
lines changed

6 files changed

+176
-6
lines changed

backend/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ async function runMigrations() {
191191
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'ai_extraction_disabled') THEN
192192
ALTER TABLE products ADD COLUMN ai_extraction_disabled BOOLEAN DEFAULT false;
193193
END IF;
194+
-- Per-product checking pause flag
195+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'checking_paused') THEN
196+
ALTER TABLE products ADD COLUMN checking_paused BOOLEAN DEFAULT false;
197+
END IF;
194198
END $$;
195199
`);
196200

backend/src/models/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ export interface Product {
344344
notify_back_in_stock: boolean;
345345
ai_verification_disabled: boolean;
346346
ai_extraction_disabled: boolean;
347+
checking_paused: boolean;
347348
created_at: Date;
348349
}
349350

@@ -587,8 +588,8 @@ export const productQueries = {
587588
findDueForRefresh: async (): Promise<Product[]> => {
588589
const result = await pool.query(
589590
`SELECT * FROM products
590-
WHERE next_check_at IS NULL
591-
OR next_check_at < CURRENT_TIMESTAMP`
591+
WHERE (next_check_at IS NULL OR next_check_at < CURRENT_TIMESTAMP)
592+
AND (checking_paused IS NULL OR checking_paused = false)`
592593
);
593594
return result.rows;
594595
},
@@ -638,6 +639,15 @@ export const productQueries = {
638639
);
639640
return result.rows[0]?.ai_extraction_disabled === true;
640641
},
642+
643+
bulkSetCheckingPaused: async (ids: number[], userId: number, paused: boolean): Promise<number> => {
644+
if (ids.length === 0) return 0;
645+
const result = await pool.query(
646+
`UPDATE products SET checking_paused = $1 WHERE id = ANY($2) AND user_id = $3`,
647+
[paused, ids, userId]
648+
);
649+
return result.rowCount || 0;
650+
},
641651
};
642652

643653
// Price History types and queries

backend/src/routes/products.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,31 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => {
268268
}
269269
});
270270

271+
// Bulk pause/resume checking
272+
router.post('/bulk/pause', async (req: AuthRequest, res: Response) => {
273+
try {
274+
const userId = req.userId!;
275+
const { ids, paused } = req.body;
276+
277+
if (!Array.isArray(ids) || ids.length === 0) {
278+
res.status(400).json({ error: 'Product IDs array is required' });
279+
return;
280+
}
281+
282+
if (typeof paused !== 'boolean') {
283+
res.status(400).json({ error: 'Paused status (boolean) is required' });
284+
return;
285+
}
286+
287+
const updated = await productQueries.bulkSetCheckingPaused(ids, userId, paused);
288+
res.json({
289+
message: `${updated} product(s) ${paused ? 'paused' : 'resumed'}`,
290+
updated
291+
});
292+
} catch (error) {
293+
console.error('Error bulk updating pause status:', error);
294+
res.status(500).json({ error: 'Failed to update pause status' });
295+
}
296+
});
297+
271298
export default router;

frontend/src/api/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface Product {
6767
notify_back_in_stock: boolean;
6868
ai_verification_disabled: boolean;
6969
ai_extraction_disabled: boolean;
70+
checking_paused: boolean;
7071
created_at: string;
7172
current_price: number | null;
7273
currency: string | null;
@@ -138,6 +139,9 @@ export const productsApi = {
138139
}) => api.put<Product>(`/products/${id}`, data),
139140

140141
delete: (id: number) => api.delete(`/products/${id}`),
142+
143+
bulkPause: (ids: number[], paused: boolean) =>
144+
api.post<{ message: string; updated: number }>('/products/bulk/pause', { ids, paused }),
141145
};
142146

143147
// Prices API

frontend/src/components/ProductCard.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,10 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
117117
const isNearHistoricalLow = !isHistoricalLow && product.current_price && product.min_price &&
118118
product.current_price <= product.min_price * 1.05; // Within 5% of low
119119

120+
const isPaused = product.checking_paused;
121+
120122
return (
121-
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''}`}>
123+
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''} ${isPaused ? 'checking-paused' : ''}`}>
122124
<style>{`
123125
.product-list-item {
124126
position: relative;
@@ -306,6 +308,32 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
306308
filter: grayscale(50%);
307309
}
308310
311+
.product-list-item.checking-paused {
312+
opacity: 0.6;
313+
}
314+
315+
.product-list-item.checking-paused .product-thumbnail {
316+
filter: grayscale(70%);
317+
}
318+
319+
.paused-indicator {
320+
display: inline-flex;
321+
align-items: center;
322+
gap: 0.25rem;
323+
padding: 0.125rem 0.375rem;
324+
background: var(--border);
325+
color: var(--text-secondary);
326+
border-radius: 0.25rem;
327+
font-size: 0.6875rem;
328+
font-weight: 500;
329+
text-transform: uppercase;
330+
}
331+
332+
.paused-indicator svg {
333+
width: 10px;
334+
height: 10px;
335+
}
336+
309337
.product-sparkline {
310338
flex-shrink: 0;
311339
}
@@ -581,10 +609,20 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
581609
<div className="product-progress-container">
582610
<div
583611
className={`product-progress-bar ${isComplete ? 'complete' : ''}`}
584-
style={{ width: `${progress}%` }}
612+
style={{ width: isPaused ? 0 : `${progress}%` }}
585613
/>
586614
</div>
587-
<span className="product-time-remaining">{timeRemaining}</span>
615+
{isPaused ? (
616+
<span className="paused-indicator">
617+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
618+
<rect x="6" y="4" width="4" height="16" />
619+
<rect x="14" y="4" width="4" height="16" />
620+
</svg>
621+
Paused
622+
</span>
623+
) : (
624+
<span className="product-time-remaining">{timeRemaining}</span>
625+
)}
588626
</div>
589627
);
590628
}

frontend/src/pages/Dashboard.tsx

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default function Dashboard() {
2626
const [isLoading, setIsLoading] = useState(true);
2727
const [error, setError] = useState('');
2828
const [searchQuery, setSearchQuery] = useState('');
29+
const [pauseFilter, setPauseFilter] = useState<'all' | 'active' | 'paused'>('all');
2930
const [sortBy, setSortBy] = useState<SortOption>(() => {
3031
const saved = localStorage.getItem('dashboard_sort_by');
3132
return (saved as SortOption) || 'date_added';
@@ -253,6 +254,42 @@ export default function Dashboard() {
253254
}
254255
};
255256

257+
const handleBulkPause = async () => {
258+
setIsSavingBulk(true);
259+
setShowBulkActions(false);
260+
try {
261+
await productsApi.bulkPause(Array.from(selectedIds), true);
262+
setProducts(prev =>
263+
prev.map(p =>
264+
selectedIds.has(p.id) ? { ...p, checking_paused: true } : p
265+
)
266+
);
267+
setSelectedIds(new Set());
268+
} catch {
269+
alert('Failed to pause some products');
270+
} finally {
271+
setIsSavingBulk(false);
272+
}
273+
};
274+
275+
const handleBulkResume = async () => {
276+
setIsSavingBulk(true);
277+
setShowBulkActions(false);
278+
try {
279+
await productsApi.bulkPause(Array.from(selectedIds), false);
280+
setProducts(prev =>
281+
prev.map(p =>
282+
selectedIds.has(p.id) ? { ...p, checking_paused: false } : p
283+
)
284+
);
285+
setSelectedIds(new Set());
286+
} catch {
287+
alert('Failed to resume some products');
288+
} finally {
289+
setIsSavingBulk(false);
290+
}
291+
};
292+
256293
const clearSelection = () => {
257294
setSelectedIds(new Set());
258295
setShowBulkActions(false);
@@ -301,6 +338,13 @@ export default function Dashboard() {
301338
const filteredAndSortedProducts = useMemo(() => {
302339
let result = [...products];
303340

341+
// Filter by pause status
342+
if (pauseFilter === 'active') {
343+
result = result.filter(p => !p.checking_paused);
344+
} else if (pauseFilter === 'paused') {
345+
result = result.filter(p => p.checking_paused);
346+
}
347+
304348
// Filter by search query
305349
if (searchQuery.trim()) {
306350
const query = searchQuery.toLowerCase();
@@ -340,7 +384,7 @@ export default function Dashboard() {
340384
});
341385

342386
return result;
343-
}, [products, searchQuery, sortBy, sortOrder]);
387+
}, [products, pauseFilter, searchQuery, sortBy, sortOrder]);
344388

345389
return (
346390
<Layout>
@@ -431,6 +475,26 @@ export default function Dashboard() {
431475
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
432476
}
433477
478+
.filter-select {
479+
padding: 0.75rem 2rem 0.75rem 0.875rem;
480+
border: 1px solid var(--border);
481+
border-radius: 0.5rem;
482+
background: var(--surface);
483+
color: var(--text);
484+
font-size: 0.9375rem;
485+
cursor: pointer;
486+
appearance: none;
487+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E");
488+
background-repeat: no-repeat;
489+
background-position: right 0.75rem center;
490+
}
491+
492+
.filter-select:focus {
493+
outline: none;
494+
border-color: var(--primary);
495+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
496+
}
497+
434498
.sort-order-btn {
435499
padding: 0.75rem;
436500
border: 1px solid var(--border);
@@ -763,6 +827,15 @@ export default function Dashboard() {
763827
/>
764828
</div>
765829
<div className="sort-controls">
830+
<select
831+
className="filter-select"
832+
value={pauseFilter}
833+
onChange={(e) => setPauseFilter(e.target.value as 'all' | 'active' | 'paused')}
834+
>
835+
<option value="all">All Products</option>
836+
<option value="active">Active</option>
837+
<option value="paused">Paused</option>
838+
</select>
766839
<select
767840
className="sort-select"
768841
value={sortBy}
@@ -834,6 +907,20 @@ export default function Dashboard() {
834907
Enable Stock Alerts
835908
</button>
836909
<hr />
910+
<button onClick={handleBulkPause}>
911+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
912+
<rect x="6" y="4" width="4" height="16" />
913+
<rect x="14" y="4" width="4" height="16" />
914+
</svg>
915+
Pause Checking
916+
</button>
917+
<button onClick={handleBulkResume}>
918+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
919+
<polygon points="5 3 19 12 5 21 5 3" />
920+
</svg>
921+
Resume Checking
922+
</button>
923+
<hr />
837924
<button className="danger" onClick={handleBulkDelete}>
838925
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
839926
<path d="M3 6h18" />

0 commit comments

Comments
 (0)