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>
This commit is contained in:
clucraft 2026-01-25 21:04:02 -05:00
parent 1f668239bd
commit 26a802e3d0
6 changed files with 176 additions and 6 deletions

View file

@ -191,6 +191,10 @@ async function runMigrations() {
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'ai_extraction_disabled') THEN
ALTER TABLE products ADD COLUMN ai_extraction_disabled BOOLEAN DEFAULT false;
END IF;
-- Per-product checking pause flag
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'checking_paused') THEN
ALTER TABLE products ADD COLUMN checking_paused BOOLEAN DEFAULT false;
END IF;
END $$;
`);

View file

@ -344,6 +344,7 @@ export interface Product {
notify_back_in_stock: boolean;
ai_verification_disabled: boolean;
ai_extraction_disabled: boolean;
checking_paused: boolean;
created_at: Date;
}
@ -587,8 +588,8 @@ export const productQueries = {
findDueForRefresh: async (): Promise<Product[]> => {
const result = await pool.query(
`SELECT * FROM products
WHERE next_check_at IS NULL
OR next_check_at < CURRENT_TIMESTAMP`
WHERE (next_check_at IS NULL OR next_check_at < CURRENT_TIMESTAMP)
AND (checking_paused IS NULL OR checking_paused = false)`
);
return result.rows;
},
@ -638,6 +639,15 @@ export const productQueries = {
);
return result.rows[0]?.ai_extraction_disabled === true;
},
bulkSetCheckingPaused: async (ids: number[], userId: number, paused: boolean): Promise<number> => {
if (ids.length === 0) return 0;
const result = await pool.query(
`UPDATE products SET checking_paused = $1 WHERE id = ANY($2) AND user_id = $3`,
[paused, ids, userId]
);
return result.rowCount || 0;
},
};
// Price History types and queries

View file

@ -268,4 +268,31 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => {
}
});
// Bulk pause/resume checking
router.post('/bulk/pause', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const { ids, paused } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'Product IDs array is required' });
return;
}
if (typeof paused !== 'boolean') {
res.status(400).json({ error: 'Paused status (boolean) is required' });
return;
}
const updated = await productQueries.bulkSetCheckingPaused(ids, userId, paused);
res.json({
message: `${updated} product(s) ${paused ? 'paused' : 'resumed'}`,
updated
});
} catch (error) {
console.error('Error bulk updating pause status:', error);
res.status(500).json({ error: 'Failed to update pause status' });
}
});
export default router;

View file

@ -67,6 +67,7 @@ export interface Product {
notify_back_in_stock: boolean;
ai_verification_disabled: boolean;
ai_extraction_disabled: boolean;
checking_paused: boolean;
created_at: string;
current_price: number | null;
currency: string | null;
@ -138,6 +139,9 @@ export const productsApi = {
}) => api.put<Product>(`/products/${id}`, data),
delete: (id: number) => api.delete(`/products/${id}`),
bulkPause: (ids: number[], paused: boolean) =>
api.post<{ message: string; updated: number }>('/products/bulk/pause', { ids, paused }),
};
// Prices API

View file

@ -117,8 +117,10 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
const isNearHistoricalLow = !isHistoricalLow && product.current_price && product.min_price &&
product.current_price <= product.min_price * 1.05; // Within 5% of low
const isPaused = product.checking_paused;
return (
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''}`}>
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''} ${isPaused ? 'checking-paused' : ''}`}>
<style>{`
.product-list-item {
position: relative;
@ -306,6 +308,32 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
filter: grayscale(50%);
}
.product-list-item.checking-paused {
opacity: 0.6;
}
.product-list-item.checking-paused .product-thumbnail {
filter: grayscale(70%);
}
.paused-indicator {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background: var(--border);
color: var(--text-secondary);
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
.paused-indicator svg {
width: 10px;
height: 10px;
}
.product-sparkline {
flex-shrink: 0;
}
@ -581,10 +609,20 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
<div className="product-progress-container">
<div
className={`product-progress-bar ${isComplete ? 'complete' : ''}`}
style={{ width: `${progress}%` }}
style={{ width: isPaused ? 0 : `${progress}%` }}
/>
</div>
<span className="product-time-remaining">{timeRemaining}</span>
{isPaused ? (
<span className="paused-indicator">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
Paused
</span>
) : (
<span className="product-time-remaining">{timeRemaining}</span>
)}
</div>
);
}

View file

@ -26,6 +26,7 @@ export default function Dashboard() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [pauseFilter, setPauseFilter] = useState<'all' | 'active' | 'paused'>('all');
const [sortBy, setSortBy] = useState<SortOption>(() => {
const saved = localStorage.getItem('dashboard_sort_by');
return (saved as SortOption) || 'date_added';
@ -253,6 +254,42 @@ export default function Dashboard() {
}
};
const handleBulkPause = async () => {
setIsSavingBulk(true);
setShowBulkActions(false);
try {
await productsApi.bulkPause(Array.from(selectedIds), true);
setProducts(prev =>
prev.map(p =>
selectedIds.has(p.id) ? { ...p, checking_paused: true } : p
)
);
setSelectedIds(new Set());
} catch {
alert('Failed to pause some products');
} finally {
setIsSavingBulk(false);
}
};
const handleBulkResume = async () => {
setIsSavingBulk(true);
setShowBulkActions(false);
try {
await productsApi.bulkPause(Array.from(selectedIds), false);
setProducts(prev =>
prev.map(p =>
selectedIds.has(p.id) ? { ...p, checking_paused: false } : p
)
);
setSelectedIds(new Set());
} catch {
alert('Failed to resume some products');
} finally {
setIsSavingBulk(false);
}
};
const clearSelection = () => {
setSelectedIds(new Set());
setShowBulkActions(false);
@ -301,6 +338,13 @@ export default function Dashboard() {
const filteredAndSortedProducts = useMemo(() => {
let result = [...products];
// Filter by pause status
if (pauseFilter === 'active') {
result = result.filter(p => !p.checking_paused);
} else if (pauseFilter === 'paused') {
result = result.filter(p => p.checking_paused);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
@ -340,7 +384,7 @@ export default function Dashboard() {
});
return result;
}, [products, searchQuery, sortBy, sortOrder]);
}, [products, pauseFilter, searchQuery, sortBy, sortOrder]);
return (
<Layout>
@ -431,6 +475,26 @@ export default function Dashboard() {
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.filter-select {
padding: 0.75rem 2rem 0.75rem 0.875rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
font-size: 0.9375rem;
cursor: pointer;
appearance: none;
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");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.filter-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.sort-order-btn {
padding: 0.75rem;
border: 1px solid var(--border);
@ -763,6 +827,15 @@ export default function Dashboard() {
/>
</div>
<div className="sort-controls">
<select
className="filter-select"
value={pauseFilter}
onChange={(e) => setPauseFilter(e.target.value as 'all' | 'active' | 'paused')}
>
<option value="all">All Products</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
</select>
<select
className="sort-select"
value={sortBy}
@ -834,6 +907,20 @@ export default function Dashboard() {
Enable Stock Alerts
</button>
<hr />
<button onClick={handleBulkPause}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
Pause Checking
</button>
<button onClick={handleBulkResume}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
Resume Checking
</button>
<hr />
<button className="danger" onClick={handleBulkDelete}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />