mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-05 13:53:00 +02:00
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:
parent
2acc47c21c
commit
a85e22d8bc
9 changed files with 454 additions and 5 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue