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);

View file

@ -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,
});

View file

@ -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';

View file

@ -91,6 +91,34 @@ async function checkPrices(): Promise<void> {
}
}
// 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,

View file

@ -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,

View file

@ -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<Product>(`/products/${id}`, data),

View file

@ -7,9 +7,12 @@ interface ProductCardProps {
product: Product;
onDelete: (id: number) => void;
onRefresh: (id: number) => Promise<void>;
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 (
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''}`}>
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''}`}>
<style>{`
.product-list-item {
background: var(--surface);
@ -73,6 +82,19 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
transform: translateY(-1px);
}
.product-list-item.selected {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.product-checkbox {
flex-shrink: 0;
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--primary);
}
.product-thumbnail {
width: 64px;
height: 64px;
@ -199,6 +221,26 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
color: #f87171;
}
.product-stock-badge.historical-low {
background: #ecfdf5;
color: #059669;
}
[data-theme="dark"] .product-stock-badge.historical-low {
background: rgba(5, 150, 105, 0.2);
color: #34d399;
}
.product-stock-badge.near-low {
background: #fef3c7;
color: #d97706;
}
[data-theme="dark"] .product-stock-badge.near-low {
background: rgba(217, 119, 6, 0.2);
color: #fbbf24;
}
.product-list-item.out-of-stock {
opacity: 0.7;
}
@ -284,6 +326,15 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
}
`}</style>
{showCheckbox && (
<input
type="checkbox"
className="product-checkbox"
checked={isSelected}
onChange={(e) => onSelect?.(product.id, e.target.checked)}
/>
)}
{product.image_url ? (
<img
src={product.image_url}
@ -297,7 +348,7 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
<div className="product-info">
<h3 className="product-name">{product.name || 'Unknown Product'}</h3>
<p className="product-source">{truncateUrl(product.url)}</p>
{(product.price_drop_threshold || product.notify_back_in_stock) && (
{(product.price_drop_threshold || product.target_price || product.notify_back_in_stock) && (
<div className="product-notifications">
{product.price_drop_threshold && (
<span className="product-notification-badge" title="Price drop alert">
@ -308,6 +359,16 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
${Number(product.price_drop_threshold).toFixed(2)} drop
</span>
)}
{product.target_price && (
<span className="product-notification-badge" title="Target price alert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
Target: ${Number(product.target_price).toFixed(2)}
</span>
)}
{product.notify_back_in_stock && (
<span className="product-notification-badge" title="Back in stock alert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -336,6 +397,16 @@ export default function ProductCard({ product, onDelete, onRefresh }: ProductCar
{formatPriceChange(product.price_change_7d)} (7d)
</span>
)}
{isHistoricalLow && (
<span className="product-stock-badge historical-low">
Lowest Price
</span>
)}
{isNearHistoricalLow && (
<span className="product-stock-badge near-low">
Near Low
</span>
)}
</>
)}
</div>

View file

@ -28,6 +28,9 @@ export default function Dashboard() {
const saved = localStorage.getItem('dashboard_sort_order');
return (saved as SortOrder) || 'desc';
});
const [selectedIds, setSelectedIds] = useState<Set<number>>(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);
}
`}</style>
<div className="dashboard-header">
@ -334,6 +522,43 @@ export default function Dashboard() {
{error && <div className="alert alert-error">{error}</div>}
{/* Dashboard Summary */}
{!isLoading && dashboardSummary && dashboardSummary.totalProducts > 0 && (
<div className="dashboard-summary">
<div className="summary-card">
<div className="summary-card-title">Total Products</div>
<div className="summary-card-value">{dashboardSummary.totalProducts}</div>
</div>
<div className="summary-card">
<div className="summary-card-title">At Lowest Price</div>
<div className={`summary-card-value ${dashboardSummary.atHistoricalLow.length > 0 ? 'success' : ''}`}>
{dashboardSummary.atHistoricalLow.length}
</div>
</div>
<div className="summary-card">
<div className="summary-card-title">At Target Price</div>
<div className={`summary-card-value ${dashboardSummary.atTargetPrice.length > 0 ? 'highlight' : ''}`}>
{dashboardSummary.atTargetPrice.length}
</div>
</div>
{dashboardSummary.biggestDrops.length > 0 && (
<div className="summary-card">
<div className="summary-card-title">Biggest Drops (7d)</div>
<ul className="summary-card-list">
{dashboardSummary.biggestDrops.map(p => (
<li key={p.id}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: '0.5rem' }}>
{p.name?.slice(0, 20) || 'Unknown'}
</span>
<span className="drop">{p.price_change_7d?.toFixed(1)}%</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{!isLoading && products.length > 0 && (
<div className="dashboard-controls">
<div className="search-container">
@ -389,6 +614,39 @@ export default function Dashboard() {
</svg>
</button>
</div>
<label className="bulk-mode-toggle">
<input
type="checkbox"
checked={bulkMode}
onChange={(e) => {
setBulkMode(e.target.checked);
if (!e.target.checked) setSelectedIds(new Set());
}}
/>
Select multiple
</label>
</div>
)}
{/* Bulk Action Bar */}
{bulkMode && selectedIds.size > 0 && (
<div className="bulk-action-bar">
<span className="selected-count">{selectedIds.size} selected</span>
<button className="btn btn-sm" onClick={handleSelectAll}>
{selectedIds.size === filteredAndSortedProducts.length ? 'Deselect All' : 'Select All'}
</button>
<div className="bulk-actions">
<button
className="btn btn-danger btn-sm"
onClick={handleBulkDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Selected'}
</button>
<button className="btn btn-sm" onClick={exitBulkMode}>
Cancel
</button>
</div>
</div>
)}
@ -426,6 +684,9 @@ export default function Dashboard() {
product={product}
onDelete={handleDeleteProduct}
onRefresh={handleRefreshProduct}
showCheckbox={bulkMode}
isSelected={selectedIds.has(product.id)}
onSelect={handleSelectProduct}
/>
))}
</div>

View file

@ -24,6 +24,7 @@ export default function ProductDetail() {
const [error, setError] = useState('');
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null);
const [priceDropThreshold, setPriceDropThreshold] = useState<string>('');
const [targetPrice, setTargetPrice] = useState<string>('');
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() {
</span>
</div>
<div className="notification-form-group">
<label>Target Price</label>
<input
type="number"
min="0"
step="0.01"
value={targetPrice}
onChange={(e) => setTargetPrice(e.target.value)}
placeholder="Enter target price (e.g., 49.99)"
/>
<span className="hint">
Notify when price drops to or below this amount ({product.currency || 'USD'})
</span>
</div>
</div>
<div className="notification-form-row">
<div className="notification-form-group">
<label>Back in Stock Alert</label>
<label className="notification-checkbox-group">