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 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-22 14:23:55 -05:00
parent 4928d6b9d3
commit 6c2aece1e8
8 changed files with 528 additions and 6 deletions

View file

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

View file

@ -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<StockStatusHistory[]> => {
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<StockStatusHistory | null> => {
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<StockStatusHistory | null> => {
// 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<StockStatusStats | null> => {
// 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,
};
},
};

View file

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

View file

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

View file

@ -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<void> {
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}`
);