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 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-21 13:40:39 -05:00
parent 2acc47c21c
commit a85e22d8bc
9 changed files with 454 additions and 5 deletions

View file

@ -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<number, SparklinePoint[]>();
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<number, number>();
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<Product | null> => {
@ -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);