From 6c2aece1e88e32a7a4748f4522fd4cb4a0103969 Mon Sep 17 00:00:00 2001 From: clucraft Date: Thu, 22 Jan 2026 14:23:55 -0500 Subject: [PATCH] Add stock status history tracking and timeline visualization - Create stock_status_history table to track status changes over time - Add stockStatusHistoryQueries with getByProductId, recordChange, getStats - Update scheduler to record status changes - Update product creation and manual refresh to record initial/changed status - Add GET /products/:id/stock-history API endpoint - Create StockTimeline component with: - Visual timeline bar showing in-stock (green) vs out-of-stock (red) - Availability percentage - Outage count and duration stats - Integrate timeline into ProductDetail page Co-Authored-By: Claude Opus 4.5 --- backend/src/index.ts | 14 ++ backend/src/models/index.ts | 141 +++++++++++ backend/src/routes/prices.ts | 43 +++- backend/src/routes/products.ts | 7 +- backend/src/services/scheduler.ts | 8 +- frontend/src/api/client.ts | 25 ++ frontend/src/components/StockTimeline.tsx | 293 ++++++++++++++++++++++ frontend/src/pages/ProductDetail.tsx | 3 + 8 files changed, 528 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/StockTimeline.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index b2b78cc..131a572 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -48,6 +48,20 @@ async function runMigrations() { END IF; END $$; `); + + // Create stock_status_history table if it doesn't exist + await client.query(` + CREATE TABLE IF NOT EXISTS stock_status_history ( + id SERIAL PRIMARY KEY, + product_id INTEGER REFERENCES products(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL, + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_stock_history_product_date + ON stock_status_history(product_id, changed_at); + `); + console.log('Database migrations completed'); } catch (error) { console.error('Migration error:', error); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 8855619..a8aacdf 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -597,3 +597,144 @@ export const priceHistoryQueries = { return result.rows[0] || null; }, }; + +// Stock Status History types and queries +export interface StockStatusHistory { + id: number; + product_id: number; + status: StockStatus; + changed_at: Date; +} + +export interface StockStatusStats { + availability_percent: number; + outage_count: number; + avg_outage_days: number | null; + longest_outage_days: number | null; + current_status: StockStatus; + days_in_current_status: number; +} + +export const stockStatusHistoryQueries = { + // Get all status changes for a product + getByProductId: async (productId: number, days?: number): Promise => { + let query = ` + SELECT * FROM stock_status_history + WHERE product_id = $1 + `; + const values: (number | string)[] = [productId]; + + if (days) { + query += ` AND changed_at >= CURRENT_TIMESTAMP - ($2 || ' days')::interval`; + values.push(days.toString()); + } + + query += ' ORDER BY changed_at ASC'; + + const result = await pool.query(query, values); + return result.rows; + }, + + // Get the most recent status for a product + getLatest: async (productId: number): Promise => { + const result = await pool.query( + `SELECT * FROM stock_status_history + WHERE product_id = $1 + ORDER BY changed_at DESC + LIMIT 1`, + [productId] + ); + return result.rows[0] || null; + }, + + // Record a status change (only if status actually changed) + recordChange: async (productId: number, status: StockStatus): Promise => { + // First check if this is actually a change + const latest = await stockStatusHistoryQueries.getLatest(productId); + + // If status is the same as the last recorded status, don't create a new record + if (latest && latest.status === status) { + return null; + } + + const result = await pool.query( + `INSERT INTO stock_status_history (product_id, status) + VALUES ($1, $2) + RETURNING *`, + [productId, status] + ); + return result.rows[0]; + }, + + // Calculate availability statistics + getStats: async (productId: number, days: number = 30): Promise => { + // Get all status changes within the period + const history = await stockStatusHistoryQueries.getByProductId(productId); + + if (history.length === 0) { + return null; + } + + const now = new Date(); + const periodStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + + // Calculate time spent in each status + let inStockMs = 0; + let outOfStockMs = 0; + const outages: number[] = []; // Duration of each outage in ms + let currentOutageStart: Date | null = null; + + for (let i = 0; i < history.length; i++) { + const entry = history[i]; + const entryTime = new Date(entry.changed_at); + const nextEntry = history[i + 1]; + const nextTime = nextEntry ? new Date(nextEntry.changed_at) : now; + + // Only count time within our period + const segmentStart = entryTime < periodStart ? periodStart : entryTime; + const segmentEnd = nextTime; + + if (segmentEnd <= periodStart) continue; // This segment is before our period + + const duration = segmentEnd.getTime() - segmentStart.getTime(); + + if (entry.status === 'in_stock') { + inStockMs += duration; + if (currentOutageStart) { + // Outage ended + outages.push(entryTime.getTime() - currentOutageStart.getTime()); + currentOutageStart = null; + } + } else if (entry.status === 'out_of_stock') { + outOfStockMs += duration; + if (!currentOutageStart) { + currentOutageStart = entryTime; + } + } + } + + const totalMs = inStockMs + outOfStockMs; + const availabilityPercent = totalMs > 0 ? Math.round((inStockMs / totalMs) * 100) : 0; + + const avgOutageDays = outages.length > 0 + ? outages.reduce((a, b) => a + b, 0) / outages.length / (24 * 60 * 60 * 1000) + : null; + + const longestOutageDays = outages.length > 0 + ? Math.max(...outages) / (24 * 60 * 60 * 1000) + : null; + + const currentStatus = history[history.length - 1].status; + const lastChangeTime = new Date(history[history.length - 1].changed_at); + const daysInCurrentStatus = Math.floor((now.getTime() - lastChangeTime.getTime()) / (24 * 60 * 60 * 1000)); + + return { + availability_percent: availabilityPercent, + outage_count: outages.length, + avg_outage_days: avgOutageDays ? Math.round(avgOutageDays * 10) / 10 : null, + longest_outage_days: longestOutageDays ? Math.round(longestOutageDays * 10) / 10 : null, + current_status: currentStatus, + days_in_current_status: daysInCurrentStatus, + }; + }, +}; diff --git a/backend/src/routes/prices.ts b/backend/src/routes/prices.ts index 1697678..f25e11f 100644 --- a/backend/src/routes/prices.ts +++ b/backend/src/routes/prices.ts @@ -1,6 +1,6 @@ import { Router, Response } from 'express'; import { AuthRequest, authMiddleware } from '../middleware/auth'; -import { productQueries, priceHistoryQueries } from '../models'; +import { productQueries, priceHistoryQueries, stockStatusHistoryQueries } from '../models'; import { scrapeProduct } from '../services/scraper'; const router = Router(); @@ -65,8 +65,11 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => { // Scrape product data including price and stock status const scrapedData = await scrapeProduct(product.url); - // Update stock status - await productQueries.updateStockStatus(productId, scrapedData.stockStatus); + // Update stock status and record change if different + if (scrapedData.stockStatus !== product.stock_status) { + await productQueries.updateStockStatus(productId, scrapedData.stockStatus); + await stockStatusHistoryQueries.recordChange(productId, scrapedData.stockStatus); + } // Record new price if available let newPrice = null; @@ -94,4 +97,38 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => { } }); +// Get stock status history for a product +router.get('/:productId/stock-history', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const productId = parseInt(req.params.productId, 10); + + if (isNaN(productId)) { + res.status(400).json({ error: 'Invalid product ID' }); + return; + } + + // Verify product belongs to user + const product = await productQueries.findById(productId, userId); + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + // Get optional days filter from query (default 30 days) + const days = req.query.days ? parseInt(req.query.days as string, 10) : 30; + + const stockHistory = await stockStatusHistoryQueries.getByProductId(productId, days); + const stats = await stockStatusHistoryQueries.getStats(productId, days); + + res.json({ + history: stockHistory, + stats, + }); + } catch (error) { + console.error('Error fetching stock status history:', error); + res.status(500).json({ error: 'Failed to fetch stock status history' }); + } +}); + export default router; diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts index ae20862..54f2b25 100644 --- a/backend/src/routes/products.ts +++ b/backend/src/routes/products.ts @@ -1,6 +1,6 @@ import { Router, Response } from 'express'; import { AuthRequest, authMiddleware } from '../middleware/auth'; -import { productQueries, priceHistoryQueries } from '../models'; +import { productQueries, priceHistoryQueries, stockStatusHistoryQueries } from '../models'; import { scrapeProduct } from '../services/scraper'; const router = Router(); @@ -69,6 +69,11 @@ router.post('/', async (req: AuthRequest, res: Response) => { ); } + // Record initial stock status + if (scrapedData.stockStatus !== 'unknown') { + await stockStatusHistoryQueries.recordChange(product.id, scrapedData.stockStatus); + } + // Update last_checked timestamp and schedule next check await productQueries.updateLastChecked(product.id, product.refresh_interval); diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 152734f..e3cfc0d 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -1,5 +1,5 @@ import cron from 'node-cron'; -import { productQueries, priceHistoryQueries, userQueries } from '../models'; +import { productQueries, priceHistoryQueries, userQueries, stockStatusHistoryQueries } from '../models'; import { scrapeProduct } from './scraper'; import { sendNotifications, NotificationPayload } from './notifications'; @@ -29,9 +29,13 @@ async function checkPrices(): Promise { const wasOutOfStock = product.stock_status === 'out_of_stock'; const nowInStock = scrapedData.stockStatus === 'in_stock'; - // Update stock status + // Update stock status and record to history if (scrapedData.stockStatus !== product.stock_status) { await productQueries.updateStockStatus(product.id, scrapedData.stockStatus); + + // Record the status change in history + await stockStatusHistoryQueries.recordChange(product.id, scrapedData.stockStatus); + console.log( `Stock status changed for product ${product.id}: ${product.stock_status} -> ${scrapedData.stockStatus}` ); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3c3f0d6..411bef1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -122,6 +122,31 @@ export const pricesApi = { ), }; +// Stock Status History API +export interface StockStatusHistoryEntry { + id: number; + product_id: number; + status: StockStatus; + changed_at: string; +} + +export interface StockStatusStats { + availability_percent: number; + outage_count: number; + avg_outage_days: number | null; + longest_outage_days: number | null; + current_status: StockStatus; + days_in_current_status: number; +} + +export const stockHistoryApi = { + getHistory: (productId: number, days?: number) => + api.get<{ history: StockStatusHistoryEntry[]; stats: StockStatusStats | null }>( + `/products/${productId}/stock-history`, + { params: days ? { days } : undefined } + ), +}; + // Settings API export interface NotificationSettings { telegram_configured: boolean; diff --git a/frontend/src/components/StockTimeline.tsx b/frontend/src/components/StockTimeline.tsx new file mode 100644 index 0000000..5db3a85 --- /dev/null +++ b/frontend/src/components/StockTimeline.tsx @@ -0,0 +1,293 @@ +import { useState, useEffect } from 'react'; +import { stockHistoryApi, StockStatusHistoryEntry, StockStatusStats } from '../api/client'; + +interface StockTimelineProps { + productId: number; + days?: number; +} + +export default function StockTimeline({ productId, days = 30 }: StockTimelineProps) { + const [history, setHistory] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + setIsLoading(true); + const response = await stockHistoryApi.getHistory(productId, days); + setHistory(response.data.history); + setStats(response.data.stats); + setError(null); + } catch { + setError('Failed to load stock history'); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [productId, days]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !stats || history.length === 0) { + return null; // Don't show anything if no stock history data + } + + // Calculate timeline segments + const now = new Date(); + const periodStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + const totalMs = now.getTime() - periodStart.getTime(); + + const segments: { status: string; startPercent: number; widthPercent: number }[] = []; + + for (let i = 0; i < history.length; i++) { + const entry = history[i]; + const entryTime = new Date(entry.changed_at); + const nextEntry = history[i + 1]; + const nextTime = nextEntry ? new Date(nextEntry.changed_at) : now; + + // Clip to our period + const segmentStart = entryTime < periodStart ? periodStart : entryTime; + const segmentEnd = nextTime; + + if (segmentEnd <= periodStart) continue; + + const startPercent = ((segmentStart.getTime() - periodStart.getTime()) / totalMs) * 100; + const widthPercent = ((segmentEnd.getTime() - segmentStart.getTime()) / totalMs) * 100; + + segments.push({ + status: entry.status, + startPercent: Math.max(0, startPercent), + widthPercent: Math.min(100 - startPercent, widthPercent), + }); + } + + return ( + <> + + +
+
+ 📊 +

Stock Availability

+ Last {days} days +
+ +
+
+ {segments.map((segment, index) => ( +
+ ))} +
+
+
+
+ In Stock +
+
+
+ Out of Stock +
+
+
+ +
+
+
= 80 ? 'good' : stats.availability_percent >= 50 ? 'neutral' : 'bad'}`}> + {stats.availability_percent}% +
+
Availability
+
+ +
+
+ {stats.outage_count} +
+
Times Out of Stock
+
+ + {stats.avg_outage_days !== null && ( +
+
+ {stats.avg_outage_days}d +
+
Avg Outage
+
+ )} + + {stats.longest_outage_days !== null && ( +
+
+ {stats.longest_outage_days}d +
+
Longest Outage
+
+ )} + +
+
+ {stats.days_in_current_status}d +
+
+ {stats.current_status === 'in_stock' ? 'In Stock' : 'Out of Stock'} +
+
+
+
+ + ); +} diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index a0fed55..35e1095 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import Layout from '../components/Layout'; import PriceChart from '../components/PriceChart'; +import StockTimeline from '../components/StockTimeline'; import { useToast } from '../context/ToastContext'; import { productsApi, @@ -521,6 +522,8 @@ export default function ProductDetail() { onRangeChange={handleRangeChange} /> + + {notificationSettings && ( (notificationSettings.telegram_configured && notificationSettings.telegram_enabled) || (notificationSettings.discord_configured && notificationSettings.discord_enabled) ||