From a85e22d8bc3de8b2f644e04b955d03c7d11d3901 Mon Sep 17 00:00:00 2001 From: clucraft Date: Wed, 21 Jan 2026 13:40:39 -0500 Subject: [PATCH] Add target price alerts, historical low indicator, bulk actions, and dashboard summary Features: - Target price alerts: Set a specific price target and get notified when reached - Historical low indicator: Badge showing when current price is at/near all-time low - Bulk actions: Select multiple products to delete at once - Dashboard summary: Shows total products, items at lowest price, at target, biggest drops Backend changes: - Add target_price column to products table - Add target_price notification type with Telegram/Discord support - Include min_price in product queries for historical low detection - Update scheduler to check target price conditions Frontend changes: - Add target price input to ProductDetail notification settings - Show target price badge on product cards - Add "Lowest Price" and "Near Low" badges to product cards - Add bulk selection mode with checkboxes - Add dashboard summary cards at top of product list Co-Authored-By: Claude Opus 4.5 --- backend/src/models/index.ts | 23 +++ backend/src/routes/products.ts | 3 +- backend/src/services/notifications.ts | 28 ++- backend/src/services/scheduler.ts | 28 +++ database/init.sql | 12 ++ frontend/src/api/client.ts | 3 + frontend/src/components/ProductCard.tsx | 77 ++++++- frontend/src/pages/Dashboard.tsx | 261 ++++++++++++++++++++++++ frontend/src/pages/ProductDetail.tsx | 24 +++ 9 files changed, 454 insertions(+), 5 deletions(-) diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 98e3f7c..5a8e0b0 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -200,6 +200,7 @@ export interface Product { next_check_at: Date | null; stock_status: StockStatus; price_drop_threshold: number | null; + target_price: number | null; notify_back_in_stock: boolean; created_at: Date; } @@ -222,6 +223,7 @@ export interface SparklinePoint { export interface ProductWithSparkline extends ProductWithLatestPrice { sparkline: SparklinePoint[]; price_change_7d: number | null; + min_price: number | null; } export const productQueries = { @@ -272,6 +274,15 @@ export const productQueries = { [productIds] ); + // Get min prices for all products (all-time low) + const minPriceResult = await pool.query( + `SELECT product_id, MIN(price) as min_price + FROM price_history + WHERE product_id = ANY($1) + GROUP BY product_id`, + [productIds] + ); + // Group sparkline data by product const sparklineMap = new Map(); for (const row of sparklineResult.rows) { @@ -280,6 +291,12 @@ export const productQueries = { sparklineMap.set(row.product_id, points); } + // Map min prices by product + const minPriceMap = new Map(); + for (const row of minPriceResult.rows) { + minPriceMap.set(row.product_id, parseFloat(row.min_price)); + } + // Combine products with sparkline data return products.map((product: ProductWithLatestPrice) => { const sparkline = sparklineMap.get(product.id) || []; @@ -297,6 +314,7 @@ export const productQueries = { ...product, sparkline, price_change_7d: priceChange7d, + min_price: minPriceMap.get(product.id) || null, }; }); }, @@ -344,6 +362,7 @@ export const productQueries = { name?: string; refresh_interval?: number; price_drop_threshold?: number | null; + target_price?: number | null; notify_back_in_stock?: boolean; } ): Promise => { @@ -363,6 +382,10 @@ export const productQueries = { fields.push(`price_drop_threshold = $${paramIndex++}`); values.push(updates.price_drop_threshold); } + if (updates.target_price !== undefined) { + fields.push(`target_price = $${paramIndex++}`); + values.push(updates.target_price); + } if (updates.notify_back_in_stock !== undefined) { fields.push(`notify_back_in_stock = $${paramIndex++}`); values.push(updates.notify_back_in_stock); diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts index caad045..166e608 100644 --- a/backend/src/routes/products.ts +++ b/backend/src/routes/products.ts @@ -129,12 +129,13 @@ router.put('/:id', async (req: AuthRequest, res: Response) => { return; } - const { name, refresh_interval, price_drop_threshold, notify_back_in_stock } = req.body; + const { name, refresh_interval, price_drop_threshold, target_price, notify_back_in_stock } = req.body; const product = await productQueries.update(productId, userId, { name, refresh_interval, price_drop_threshold, + target_price, notify_back_in_stock, }); diff --git a/backend/src/services/notifications.ts b/backend/src/services/notifications.ts index a463366..99015c3 100644 --- a/backend/src/services/notifications.ts +++ b/backend/src/services/notifications.ts @@ -3,11 +3,12 @@ import axios from 'axios'; export interface NotificationPayload { productName: string; productUrl: string; - type: 'price_drop' | 'back_in_stock'; + type: 'price_drop' | 'back_in_stock' | 'target_price'; oldPrice?: number; newPrice?: number; currency?: string; threshold?: number; + targetPrice?: number; } function formatMessage(payload: NotificationPayload): string { @@ -27,6 +28,16 @@ function formatMessage(payload: NotificationPayload): string { `🔗 ${payload.productUrl}`; } + if (payload.type === 'target_price') { + const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A'; + const targetPriceStr = payload.targetPrice ? `${currencySymbol}${payload.targetPrice.toFixed(2)}` : 'N/A'; + + return `🎯 Target Price Reached!\n\n` + + `📦 ${payload.productName}\n\n` + + `💰 Price is now ${newPriceStr} (your target: ${targetPriceStr})\n\n` + + `🔗 ${payload.productUrl}`; + } + if (payload.type === 'back_in_stock') { const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : ''; return `🎉 Back in Stock!\n\n` + @@ -85,6 +96,21 @@ export async function sendDiscordNotification( url: payload.productUrl, timestamp: new Date().toISOString(), }; + } else if (payload.type === 'target_price') { + const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A'; + const targetPriceStr = payload.targetPrice ? `${currencySymbol}${payload.targetPrice.toFixed(2)}` : 'N/A'; + + embed = { + title: '🎯 Target Price Reached!', + description: payload.productName, + color: 0xf59e0b, // Amber + fields: [ + { name: 'Current Price', value: newPriceStr, inline: true }, + { name: 'Your Target', value: targetPriceStr, inline: true }, + ], + url: payload.productUrl, + timestamp: new Date().toISOString(), + }; } else { const priceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'Check link'; diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index a42467e..fe8c7ef 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -91,6 +91,34 @@ async function checkPrices(): Promise { } } + // Check for target price notification + if (product.target_price) { + const newPrice = scrapedData.price.price; + const targetPrice = parseFloat(String(product.target_price)); + const oldPrice = latestPrice ? parseFloat(String(latestPrice.price)) : null; + + // Only notify if price just dropped to or below target (wasn't already below) + if (newPrice <= targetPrice && (!oldPrice || oldPrice > targetPrice)) { + try { + const userSettings = await userQueries.getNotificationSettings(product.user_id); + if (userSettings) { + const payload: NotificationPayload = { + productName: product.name || 'Unknown Product', + productUrl: product.url, + type: 'target_price', + newPrice: newPrice, + currency: scrapedData.price.currency, + targetPrice: targetPrice, + }; + await sendNotifications(userSettings, payload); + console.log(`Target price notification sent for product ${product.id}: ${newPrice} <= ${targetPrice}`); + } + } catch (notifyError) { + console.error(`Failed to send target price notification for product ${product.id}:`, notifyError); + } + } + } + await priceHistoryQueries.create( product.id, scrapedData.price.price, diff --git a/database/init.sql b/database/init.sql index dd7b250..d4e2355 100644 --- a/database/init.sql +++ b/database/init.sql @@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS products ( next_check_at TIMESTAMP, stock_status VARCHAR(20) DEFAULT 'unknown', price_drop_threshold DECIMAL(10,2), + target_price DECIMAL(10,2), notify_back_in_stock BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, url) @@ -107,6 +108,17 @@ BEGIN END IF; END $$; +-- Migration: Add target_price column for price alerts +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'products' AND column_name = 'target_price' + ) THEN + ALTER TABLE products ADD COLUMN target_price DECIMAL(10,2); + END IF; +END $$; + -- Price history table CREATE TABLE IF NOT EXISTS price_history ( id SERIAL PRIMARY KEY, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 88db844..f39f849 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -61,12 +61,14 @@ export interface Product { last_checked: string | null; stock_status: StockStatus; price_drop_threshold: number | null; + target_price: number | null; notify_back_in_stock: boolean; created_at: string; current_price: number | null; currency: string | null; sparkline?: SparklinePoint[]; price_change_7d?: number | null; + min_price?: number | null; } export interface ProductWithStats extends Product { @@ -98,6 +100,7 @@ export const productsApi = { name?: string; refresh_interval?: number; price_drop_threshold?: number | null; + target_price?: number | null; notify_back_in_stock?: boolean; }) => api.put(`/products/${id}`, data), diff --git a/frontend/src/components/ProductCard.tsx b/frontend/src/components/ProductCard.tsx index a2da673..494d3b6 100644 --- a/frontend/src/components/ProductCard.tsx +++ b/frontend/src/components/ProductCard.tsx @@ -7,9 +7,12 @@ interface ProductCardProps { product: Product; onDelete: (id: number) => void; onRefresh: (id: number) => Promise; + isSelected?: boolean; + onSelect?: (id: number, selected: boolean) => void; + showCheckbox?: boolean; } -export default function ProductCard({ product, onDelete, onRefresh }: ProductCardProps) { +export default function ProductCard({ product, onDelete, onRefresh, isSelected, onSelect, showCheckbox }: ProductCardProps) { const [isRefreshing, setIsRefreshing] = useState(false); const handleRefresh = async () => { @@ -54,8 +57,14 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar const isOutOfStock = product.stock_status === 'out_of_stock'; + // Check if current price is at or near historical low + const isHistoricalLow = product.current_price && product.min_price && + product.current_price <= product.min_price; + const isNearHistoricalLow = !isHistoricalLow && product.current_price && product.min_price && + product.current_price <= product.min_price * 1.05; // Within 5% of low + return ( -
+
+ {showCheckbox && ( + onSelect?.(product.id, e.target.checked)} + /> + )} + {product.image_url ? (

{product.name || 'Unknown Product'}

{truncateUrl(product.url)}

- {(product.price_drop_threshold || product.notify_back_in_stock) && ( + {(product.price_drop_threshold || product.target_price || product.notify_back_in_stock) && (
{product.price_drop_threshold && ( @@ -308,6 +359,16 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar ${Number(product.price_drop_threshold).toFixed(2)} drop )} + {product.target_price && ( + + + + + + + Target: ${Number(product.target_price).toFixed(2)} + + )} {product.notify_back_in_stock && ( @@ -336,6 +397,16 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar {formatPriceChange(product.price_change_7d)} (7d) )} + {isHistoricalLow && ( + + Lowest Price + + )} + {isNearHistoricalLow && ( + + Near Low + + )} )}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 540aa30..35d2e28 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -28,6 +28,9 @@ export default function Dashboard() { const saved = localStorage.getItem('dashboard_sort_order'); return (saved as SortOrder) || 'desc'; }); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkMode, setBulkMode] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const fetchProducts = async () => { try { @@ -80,6 +83,50 @@ export default function Dashboard() { } }; + const handleSelectProduct = (id: number, selected: boolean) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (selected) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }; + + const handleSelectAll = () => { + if (selectedIds.size === filteredAndSortedProducts.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(filteredAndSortedProducts.map(p => p.id))); + } + }; + + const handleBulkDelete = async () => { + if (selectedIds.size === 0) return; + if (!confirm(`Are you sure you want to delete ${selectedIds.size} product${selectedIds.size > 1 ? 's' : ''}?`)) { + return; + } + + setIsDeleting(true); + try { + await Promise.all(Array.from(selectedIds).map(id => productsApi.delete(id))); + setProducts(prev => prev.filter(p => !selectedIds.has(p.id))); + setSelectedIds(new Set()); + setBulkMode(false); + } catch { + alert('Failed to delete some products'); + } finally { + setIsDeleting(false); + } + }; + + const exitBulkMode = () => { + setBulkMode(false); + setSelectedIds(new Set()); + }; + const getWebsite = (url: string) => { try { return new URL(url).hostname.replace('www.', ''); @@ -88,6 +135,38 @@ export default function Dashboard() { } }; + // Dashboard summary calculations + const dashboardSummary = useMemo(() => { + if (products.length === 0) return null; + + const totalProducts = products.length; + + // Find biggest drops this week (negative price_change_7d) + const biggestDrops = products + .filter(p => p.price_change_7d && p.price_change_7d < 0) + .sort((a, b) => (a.price_change_7d || 0) - (b.price_change_7d || 0)) + .slice(0, 3); + + // Find products at or below target price + const atTargetPrice = products.filter(p => + p.target_price && p.current_price && + parseFloat(String(p.current_price)) <= parseFloat(String(p.target_price)) + ); + + // Find products at historical low + const atHistoricalLow = products.filter(p => + p.current_price && p.min_price && + parseFloat(String(p.current_price)) <= parseFloat(String(p.min_price)) + ); + + return { + totalProducts, + biggestDrops, + atTargetPrice, + atHistoricalLow, + }; + }, [products]); + const filteredAndSortedProducts = useMemo(() => { let result = [...products]; @@ -321,6 +400,115 @@ export default function Dashboard() { justify-content: center; padding: 4rem; } + + .dashboard-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; + } + + .summary-card { + background: var(--surface); + border-radius: 0.75rem; + box-shadow: var(--shadow); + padding: 1rem; + } + + .summary-card-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.5rem; + } + + .summary-card-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + } + + .summary-card-value.highlight { + color: var(--primary); + } + + .summary-card-value.success { + color: #10b981; + } + + .summary-card-list { + list-style: none; + padding: 0; + margin: 0; + } + + .summary-card-list li { + font-size: 0.8125rem; + color: var(--text); + padding: 0.25rem 0; + display: flex; + justify-content: space-between; + align-items: center; + } + + .summary-card-list li span.drop { + color: #10b981; + font-weight: 600; + } + + .bulk-action-bar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--primary); + color: white; + border-radius: 0.5rem; + margin-bottom: 1rem; + } + + .bulk-action-bar .selected-count { + font-weight: 500; + } + + .bulk-action-bar .bulk-actions { + display: flex; + gap: 0.5rem; + margin-left: auto; + } + + .bulk-action-bar .btn { + background: rgba(255,255,255,0.2); + color: white; + border: 1px solid rgba(255,255,255,0.3); + } + + .bulk-action-bar .btn:hover { + background: rgba(255,255,255,0.3); + } + + .bulk-action-bar .btn.btn-danger { + background: #dc2626; + border-color: #dc2626; + } + + .bulk-action-bar .btn.btn-danger:hover { + background: #b91c1c; + } + + .bulk-mode-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-muted); + cursor: pointer; + } + + .bulk-mode-toggle input { + accent-color: var(--primary); + } `}
@@ -334,6 +522,43 @@ export default function Dashboard() { {error &&
{error}
} + {/* Dashboard Summary */} + {!isLoading && dashboardSummary && dashboardSummary.totalProducts > 0 && ( +
+
+
Total Products
+
{dashboardSummary.totalProducts}
+
+
+
At Lowest Price
+
0 ? 'success' : ''}`}> + {dashboardSummary.atHistoricalLow.length} +
+
+
+
At Target Price
+
0 ? 'highlight' : ''}`}> + {dashboardSummary.atTargetPrice.length} +
+
+ {dashboardSummary.biggestDrops.length > 0 && ( +
+
Biggest Drops (7d)
+
    + {dashboardSummary.biggestDrops.map(p => ( +
  • + + {p.name?.slice(0, 20) || 'Unknown'} + + {p.price_change_7d?.toFixed(1)}% +
  • + ))} +
+
+ )} +
+ )} + {!isLoading && products.length > 0 && (
@@ -389,6 +614,39 @@ export default function Dashboard() {
+ +
+ )} + + {/* Bulk Action Bar */} + {bulkMode && selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + +
+ + +
)} @@ -426,6 +684,9 @@ export default function Dashboard() { product={product} onDelete={handleDeleteProduct} onRefresh={handleRefreshProduct} + showCheckbox={bulkMode} + isSelected={selectedIds.has(product.id)} + onSelect={handleSelectProduct} /> ))}
diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index d2968b0..64dc9e7 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -24,6 +24,7 @@ export default function ProductDetail() { const [error, setError] = useState(''); const [notificationSettings, setNotificationSettings] = useState(null); const [priceDropThreshold, setPriceDropThreshold] = useState(''); + const [targetPrice, setTargetPrice] = useState(''); const [notifyBackInStock, setNotifyBackInStock] = useState(false); const REFRESH_INTERVALS = [ @@ -50,6 +51,9 @@ export default function ProductDetail() { if (productRes.data.price_drop_threshold !== null && productRes.data.price_drop_threshold !== undefined) { setPriceDropThreshold(productRes.data.price_drop_threshold.toString()); } + if (productRes.data.target_price !== null && productRes.data.target_price !== undefined) { + setTargetPrice(productRes.data.target_price.toString()); + } setNotifyBackInStock(productRes.data.notify_back_in_stock || false); } catch { setError('Failed to load product details'); @@ -121,13 +125,16 @@ export default function ProductDetail() { setIsSavingNotifications(true); try { const threshold = priceDropThreshold ? parseFloat(priceDropThreshold) : null; + const target = targetPrice ? parseFloat(targetPrice) : null; await productsApi.update(productId, { price_drop_threshold: threshold, + target_price: target, notify_back_in_stock: notifyBackInStock, }); setProduct({ ...product, price_drop_threshold: threshold, + target_price: target, notify_back_in_stock: notifyBackInStock, }); } catch { @@ -678,6 +685,23 @@ export default function ProductDetail() {
+
+ + setTargetPrice(e.target.value)} + placeholder="Enter target price (e.g., 49.99)" + /> + + Notify when price drops to or below this amount ({product.currency || 'USD'}) + +
+
+ +