diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index f6692f8..c06f474 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -51,6 +51,16 @@ export interface ProductWithLatestPrice extends Product { currency: string | null; } +export interface SparklinePoint { + price: number; + recorded_at: Date; +} + +export interface ProductWithSparkline extends ProductWithLatestPrice { + sparkline: SparklinePoint[]; + price_change_7d: number | null; +} + export const productQueries = { findByUserId: async (userId: number): Promise => { const result = await pool.query( @@ -69,6 +79,65 @@ export const productQueries = { return result.rows; }, + findByUserIdWithSparkline: async (userId: number): Promise => { + // Get all products with current price + const productsResult = await pool.query( + `SELECT p.*, ph.price as current_price, ph.currency + FROM products p + LEFT JOIN LATERAL ( + SELECT price, currency FROM price_history + WHERE product_id = p.id + ORDER BY recorded_at DESC + LIMIT 1 + ) ph ON true + WHERE p.user_id = $1 + ORDER BY p.created_at DESC`, + [userId] + ); + + const products = productsResult.rows; + if (products.length === 0) return []; + + // Get sparkline data for all products (last 7 days) + const productIds = products.map((p: Product) => p.id); + const sparklineResult = await pool.query( + `SELECT product_id, price, recorded_at + FROM price_history + WHERE product_id = ANY($1) + AND recorded_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' + ORDER BY product_id, recorded_at ASC`, + [productIds] + ); + + // Group sparkline data by product + const sparklineMap = new Map(); + for (const row of sparklineResult.rows) { + const points = sparklineMap.get(row.product_id) || []; + points.push({ price: row.price, recorded_at: row.recorded_at }); + sparklineMap.set(row.product_id, points); + } + + // Combine products with sparkline data + return products.map((product: ProductWithLatestPrice) => { + const sparkline = sparklineMap.get(product.id) || []; + let priceChange7d: number | null = null; + + if (sparkline.length >= 2) { + const firstPrice = parseFloat(String(sparkline[0].price)); + const lastPrice = parseFloat(String(sparkline[sparkline.length - 1].price)); + if (firstPrice > 0) { + priceChange7d = ((lastPrice - firstPrice) / firstPrice) * 100; + } + } + + return { + ...product, + sparkline, + price_change_7d: priceChange7d, + }; + }); + }, + findById: async (id: number, userId: number): Promise => { const result = await pool.query( `SELECT p.*, ph.price as current_price, ph.currency diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts index c90a4ea..e47b0b7 100644 --- a/backend/src/routes/products.ts +++ b/backend/src/routes/products.ts @@ -8,11 +8,11 @@ const router = Router(); // All routes require authentication router.use(authMiddleware); -// Get all products for the authenticated user +// Get all products for the authenticated user (with sparkline data) router.get('/', async (req: AuthRequest, res: Response) => { try { const userId = req.userId!; - const products = await productQueries.findByUserId(userId); + const products = await productQueries.findByUserIdWithSparkline(userId); res.json(products); } catch (error) { console.error('Error fetching products:', error); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 37627f9..b6680e4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -41,6 +41,11 @@ export const authApi = { }; // Products API +export interface SparklinePoint { + price: number; + recorded_at: string; +} + export interface Product { id: number; user_id: number; @@ -52,6 +57,8 @@ export interface Product { created_at: string; current_price: number | null; currency: string | null; + sparkline?: SparklinePoint[]; + price_change_7d?: number | null; } export interface ProductWithStats extends Product { diff --git a/frontend/src/components/ProductCard.tsx b/frontend/src/components/ProductCard.tsx index 6dd7bf5..ac17719 100644 --- a/frontend/src/components/ProductCard.tsx +++ b/frontend/src/components/ProductCard.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom'; import { Product } from '../api/client'; +import Sparkline from './Sparkline'; interface ProductCardProps { product: Product; @@ -16,107 +17,179 @@ export default function ProductCard({ product, onDelete }: ProductCardProps) { return `${currencySymbol}${numPrice.toFixed(2)}`; }; - const formatDate = (dateStr: string | null) => { - if (!dateStr) return 'Never'; - const date = new Date(dateStr); - return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); + const formatPriceChange = (change: number | null | undefined) => { + if (change === null || change === undefined) return null; + const sign = change > 0 ? '+' : ''; + return `${sign}${change.toFixed(1)}%`; }; - const truncateUrl = (url: string, maxLength: number = 50) => { + const truncateUrl = (url: string) => { try { const parsed = new URL(url); - const display = parsed.hostname + parsed.pathname; - return display.length > maxLength - ? display.slice(0, maxLength) + '...' - : display; + return parsed.hostname.replace('www.', ''); } catch { - return url.length > maxLength ? url.slice(0, maxLength) + '...' : url; + return url; } }; + const priceChangeClass = product.price_change_7d + ? product.price_change_7d < 0 + ? 'price-down' + : product.price_change_7d > 0 + ? 'price-up' + : '' + : ''; + return ( -
+
@@ -124,33 +197,48 @@ export default function ProductCard({ product, onDelete }: ProductCardProps) { {product.name ) : ( -
📦
+
📦
)} -
+

{product.name || 'Unknown Product'}

-

{truncateUrl(product.url)}

-
- {formatPrice(product.current_price, product.currency)} -
-

- Last checked: {formatDate(product.last_checked)} -

+

{truncateUrl(product.url)}

+
-
- - View Details - - -
+
+ + {formatPrice(product.current_price, product.currency)} + + {product.price_change_7d !== null && product.price_change_7d !== undefined && ( + + {formatPriceChange(product.price_change_7d)} (7d) + + )} +
+ +
+ +
+ +
+ + View + +
); diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx new file mode 100644 index 0000000..8ac6805 --- /dev/null +++ b/frontend/src/components/Sparkline.tsx @@ -0,0 +1,145 @@ +import { SparklinePoint } from '../api/client'; + +interface SparklineProps { + data: SparklinePoint[]; + width?: number; + height?: number; + color?: string; + showTrend?: boolean; +} + +export default function Sparkline({ + data, + width = 120, + height = 40, + color, + showTrend = true, +}: SparklineProps) { + if (!data || data.length < 2) { + return ( +
+ + No data +
+ ); + } + + const prices = data.map((d) => + typeof d.price === 'string' ? parseFloat(d.price) : d.price + ); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const priceRange = maxPrice - minPrice || 1; + + // Calculate trend + const firstPrice = prices[0]; + const lastPrice = prices[prices.length - 1]; + const trend = lastPrice < firstPrice ? 'down' : lastPrice > firstPrice ? 'up' : 'flat'; + + // Determine color based on trend (green for down = good, red for up = bad for prices) + const lineColor = color || (trend === 'down' ? '#10b981' : trend === 'up' ? '#ef4444' : '#6366f1'); + + // Create SVG path + const padding = 4; + const chartWidth = width - padding * 2; + const chartHeight = height - padding * 2; + + const points = prices.map((price, index) => { + const x = padding + (index / (prices.length - 1)) * chartWidth; + const y = padding + chartHeight - ((price - minPrice) / priceRange) * chartHeight; + return `${x},${y}`; + }); + + const pathD = `M ${points.join(' L ')}`; + + // Create gradient fill path + const fillPoints = [...points]; + fillPoints.push(`${padding + chartWidth},${padding + chartHeight}`); + fillPoints.push(`${padding},${padding + chartHeight}`); + const fillD = `M ${points.join(' L ')} L ${padding + chartWidth},${padding + chartHeight} L ${padding},${padding + chartHeight} Z`; + + return ( +
+ + + + + + + + + + + + + {showTrend && ( + + {trend === 'down' && '↓'} + {trend === 'up' && '↑'} + {trend === 'flat' && '→'} + + )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6935d9a..409c36d 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import Layout from '../components/Layout'; import ProductCard from '../components/ProductCard'; import ProductForm from '../components/ProductForm'; @@ -8,6 +8,7 @@ export default function Dashboard() { const [products, setProducts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); const fetchProducts = async () => { try { @@ -42,11 +43,21 @@ export default function Dashboard() { } }; + const filteredProducts = useMemo(() => { + if (!searchQuery.trim()) return products; + const query = searchQuery.toLowerCase(); + return products.filter( + (p) => + p.name?.toLowerCase().includes(query) || + p.url.toLowerCase().includes(query) + ); + }, [products, searchQuery]); + return (