mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-07 14:52:39 +02:00
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:
parent
4928d6b9d3
commit
6c2aece1e8
8 changed files with 528 additions and 6 deletions
|
|
@ -48,6 +48,20 @@ async function runMigrations() {
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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');
|
console.log('Database migrations completed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Migration error:', error);
|
console.error('Migration error:', error);
|
||||||
|
|
|
||||||
|
|
@ -597,3 +597,144 @@ export const priceHistoryQueries = {
|
||||||
return result.rows[0] || null;
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import { AuthRequest, authMiddleware } from '../middleware/auth';
|
import { AuthRequest, authMiddleware } from '../middleware/auth';
|
||||||
import { productQueries, priceHistoryQueries } from '../models';
|
import { productQueries, priceHistoryQueries, stockStatusHistoryQueries } from '../models';
|
||||||
import { scrapeProduct } from '../services/scraper';
|
import { scrapeProduct } from '../services/scraper';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -65,8 +65,11 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => {
|
||||||
// Scrape product data including price and stock status
|
// Scrape product data including price and stock status
|
||||||
const scrapedData = await scrapeProduct(product.url);
|
const scrapedData = await scrapeProduct(product.url);
|
||||||
|
|
||||||
// Update stock status
|
// Update stock status and record change if different
|
||||||
await productQueries.updateStockStatus(productId, scrapedData.stockStatus);
|
if (scrapedData.stockStatus !== product.stock_status) {
|
||||||
|
await productQueries.updateStockStatus(productId, scrapedData.stockStatus);
|
||||||
|
await stockStatusHistoryQueries.recordChange(productId, scrapedData.stockStatus);
|
||||||
|
}
|
||||||
|
|
||||||
// Record new price if available
|
// Record new price if available
|
||||||
let newPrice = null;
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import { AuthRequest, authMiddleware } from '../middleware/auth';
|
import { AuthRequest, authMiddleware } from '../middleware/auth';
|
||||||
import { productQueries, priceHistoryQueries } from '../models';
|
import { productQueries, priceHistoryQueries, stockStatusHistoryQueries } from '../models';
|
||||||
import { scrapeProduct } from '../services/scraper';
|
import { scrapeProduct } from '../services/scraper';
|
||||||
|
|
||||||
const router = Router();
|
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
|
// Update last_checked timestamp and schedule next check
|
||||||
await productQueries.updateLastChecked(product.id, product.refresh_interval);
|
await productQueries.updateLastChecked(product.id, product.refresh_interval);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { productQueries, priceHistoryQueries, userQueries } from '../models';
|
import { productQueries, priceHistoryQueries, userQueries, stockStatusHistoryQueries } from '../models';
|
||||||
import { scrapeProduct } from './scraper';
|
import { scrapeProduct } from './scraper';
|
||||||
import { sendNotifications, NotificationPayload } from './notifications';
|
import { sendNotifications, NotificationPayload } from './notifications';
|
||||||
|
|
||||||
|
|
@ -29,9 +29,13 @@ async function checkPrices(): Promise<void> {
|
||||||
const wasOutOfStock = product.stock_status === 'out_of_stock';
|
const wasOutOfStock = product.stock_status === 'out_of_stock';
|
||||||
const nowInStock = scrapedData.stockStatus === 'in_stock';
|
const nowInStock = scrapedData.stockStatus === 'in_stock';
|
||||||
|
|
||||||
// Update stock status
|
// Update stock status and record to history
|
||||||
if (scrapedData.stockStatus !== product.stock_status) {
|
if (scrapedData.stockStatus !== product.stock_status) {
|
||||||
await productQueries.updateStockStatus(product.id, scrapedData.stockStatus);
|
await productQueries.updateStockStatus(product.id, scrapedData.stockStatus);
|
||||||
|
|
||||||
|
// Record the status change in history
|
||||||
|
await stockStatusHistoryQueries.recordChange(product.id, scrapedData.stockStatus);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Stock status changed for product ${product.id}: ${product.stock_status} -> ${scrapedData.stockStatus}`
|
`Stock status changed for product ${product.id}: ${product.stock_status} -> ${scrapedData.stockStatus}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Settings API
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
telegram_configured: boolean;
|
telegram_configured: boolean;
|
||||||
|
|
|
||||||
293
frontend/src/components/StockTimeline.tsx
Normal file
293
frontend/src/components/StockTimeline.tsx
Normal file
|
|
@ -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<StockStatusHistoryEntry[]>([]);
|
||||||
|
const [stats, setStats] = useState<StockStatusStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="stock-timeline-loading">
|
||||||
|
<span className="spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.stock-timeline-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-period {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-bar-container {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-bar {
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-segment {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-segment:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-segment.in_stock {
|
||||||
|
background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-segment.out_of_stock {
|
||||||
|
background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-segment.unknown {
|
||||||
|
background: linear-gradient(180deg, #9ca3af 0%, #6b7280 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-legend-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-legend-dot.in_stock {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-legend-dot.out_of_stock {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-stat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-stat-value.good {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-stat-value.bad {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-stat-value.neutral {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-timeline-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="stock-timeline-card">
|
||||||
|
<div className="stock-timeline-header">
|
||||||
|
<span className="stock-timeline-icon">📊</span>
|
||||||
|
<h2 className="stock-timeline-title">Stock Availability</h2>
|
||||||
|
<span className="stock-timeline-period">Last {days} days</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stock-timeline-bar-container">
|
||||||
|
<div className="stock-timeline-bar">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`stock-timeline-segment ${segment.status}`}
|
||||||
|
style={{
|
||||||
|
left: `${segment.startPercent}%`,
|
||||||
|
width: `${segment.widthPercent}%`,
|
||||||
|
}}
|
||||||
|
title={`${segment.status === 'in_stock' ? 'In Stock' : segment.status === 'out_of_stock' ? 'Out of Stock' : 'Unknown'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="stock-timeline-legend">
|
||||||
|
<div className="stock-timeline-legend-item">
|
||||||
|
<div className="stock-timeline-legend-dot in_stock" />
|
||||||
|
<span>In Stock</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-timeline-legend-item">
|
||||||
|
<div className="stock-timeline-legend-dot out_of_stock" />
|
||||||
|
<span>Out of Stock</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stock-timeline-stats">
|
||||||
|
<div className="stock-timeline-stat">
|
||||||
|
<div className={`stock-timeline-stat-value ${stats.availability_percent >= 80 ? 'good' : stats.availability_percent >= 50 ? 'neutral' : 'bad'}`}>
|
||||||
|
{stats.availability_percent}%
|
||||||
|
</div>
|
||||||
|
<div className="stock-timeline-stat-label">Availability</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stock-timeline-stat">
|
||||||
|
<div className={`stock-timeline-stat-value ${stats.outage_count === 0 ? 'good' : stats.outage_count <= 2 ? 'neutral' : 'bad'}`}>
|
||||||
|
{stats.outage_count}
|
||||||
|
</div>
|
||||||
|
<div className="stock-timeline-stat-label">Times Out of Stock</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.avg_outage_days !== null && (
|
||||||
|
<div className="stock-timeline-stat">
|
||||||
|
<div className="stock-timeline-stat-value neutral">
|
||||||
|
{stats.avg_outage_days}d
|
||||||
|
</div>
|
||||||
|
<div className="stock-timeline-stat-label">Avg Outage</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats.longest_outage_days !== null && (
|
||||||
|
<div className="stock-timeline-stat">
|
||||||
|
<div className="stock-timeline-stat-value neutral">
|
||||||
|
{stats.longest_outage_days}d
|
||||||
|
</div>
|
||||||
|
<div className="stock-timeline-stat-label">Longest Outage</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="stock-timeline-stat">
|
||||||
|
<div className={`stock-timeline-stat-value ${stats.current_status === 'in_stock' ? 'good' : 'bad'}`}>
|
||||||
|
{stats.days_in_current_status}d
|
||||||
|
</div>
|
||||||
|
<div className="stock-timeline-stat-label">
|
||||||
|
{stats.current_status === 'in_stock' ? 'In Stock' : 'Out of Stock'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import PriceChart from '../components/PriceChart';
|
import PriceChart from '../components/PriceChart';
|
||||||
|
import StockTimeline from '../components/StockTimeline';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
import {
|
import {
|
||||||
productsApi,
|
productsApi,
|
||||||
|
|
@ -521,6 +522,8 @@ export default function ProductDetail() {
|
||||||
onRangeChange={handleRangeChange}
|
onRangeChange={handleRangeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StockTimeline productId={productId} days={30} />
|
||||||
|
|
||||||
{notificationSettings && (
|
{notificationSettings && (
|
||||||
(notificationSettings.telegram_configured && notificationSettings.telegram_enabled) ||
|
(notificationSettings.telegram_configured && notificationSettings.telegram_enabled) ||
|
||||||
(notificationSettings.discord_configured && notificationSettings.discord_enabled) ||
|
(notificationSettings.discord_configured && notificationSettings.discord_enabled) ||
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue