Files
flyer-crawler.projectium.com/components/PriceHistoryChart.tsx

187 lines
6.9 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { loadAllHistoricalItems } from '../services/supabaseClient';
import { LoadingSpinner } from './LoadingSpinner';
import type { MasterGroceryItem, FlyerItem } from '../types';
type HistoricalData = Record<string, { date: string; price: number }[]>; // price is in cents
type ChartData = { date: string; [itemName: string]: number | string };
const COLORS = ['#10B981', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
interface PriceHistoryChartProps {
watchedItems: MasterGroceryItem[];
}
export const PriceHistoryChart: React.FC<PriceHistoryChartProps> = ({ watchedItems }) => {
const [historicalData, setHistoricalData] = useState<HistoricalData>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const watchedItemsMap = useMemo(() => new Map(watchedItems.map(item => [item.id, item.name])), [watchedItems]);
useEffect(() => {
if (watchedItems.length === 0) {
setIsLoading(false);
setHistoricalData({});
return;
}
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const rawData: Pick<FlyerItem, 'master_item_id' | 'price_in_cents' | 'created_at'>[] = await loadAllHistoricalItems(watchedItems);
if (rawData.length === 0) {
setHistoricalData({});
return;
}
const processedData = rawData.reduce<HistoricalData>((acc, record) => {
if (!record.master_item_id || record.price_in_cents === null || !record.created_at) return acc;
const itemName = watchedItemsMap.get(record.master_item_id);
if (!itemName) return acc;
const priceInCents = record.price_in_cents;
const date = new Date(record.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
if(priceInCents === 0) return acc;
if (!acc[itemName]) {
acc[itemName] = [];
}
// Ensure we only store the LOWEST price for a given day
const existingEntryIndex = acc[itemName].findIndex(entry => entry.date === date);
if (existingEntryIndex > -1) {
if (priceInCents < acc[itemName][existingEntryIndex].price) {
acc[itemName][existingEntryIndex].price = priceInCents;
}
} else {
acc[itemName].push({ date, price: priceInCents });
}
return acc;
}, {});
// Filter out items that only have one data point for a meaningful trend line
const filteredData = Object.entries(processedData).reduce<HistoricalData>((acc, [key, value]) => {
if(value.length > 1){
acc[key] = value.sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}
return acc;
}, {});
setHistoricalData(filteredData);
} catch (e: any) {
setError(e.message || 'Failed to load price history.');
} finally {
setIsLoading(false);
}
};
fetchData();
}, [watchedItems, watchedItemsMap]);
const chartData = useMemo<ChartData[]>(() => {
const availableItems = Object.keys(historicalData);
if (availableItems.length === 0) return [];
const dateMap: Map<string, ChartData> = new Map();
availableItems.forEach(itemName => {
historicalData[itemName]?.forEach(({ date, price }) => {
if (!dateMap.has(date)) {
dateMap.set(date, { date });
}
// Store price in cents
dateMap.get(date)![itemName] = price;
});
});
return Array.from(dateMap.values()).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [historicalData]);
const availableItems = Object.keys(historicalData);
const renderContent = () => {
if (isLoading) {
return (
<div className="flex justify-center items-center h-full min-h-[200px]">
<LoadingSpinner /> <span className="ml-2">Loading Price History...</span>
</div>
);
}
if (error) {
return (
<div className="bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg relative h-full flex items-center justify-center" role="alert">
<p><strong>Error:</strong> {error}</p>
</div>
);
}
if (watchedItems.length === 0) {
return (
<div className="text-center py-8 h-full flex flex-col justify-center">
<p className="text-gray-500 dark:text-gray-400">Add items to your watchlist to see their price trends over time.</p>
</div>
);
}
if (availableItems.length === 0) {
return (
<div className="text-center py-8 h-full flex flex-col justify-center">
<p className="text-gray-500 dark:text-gray-400">Not enough historical data for your watched items. Process more flyers to build a trend.</p>
</div>
);
}
return (
<ResponsiveContainer>
<LineChart
data={chartData}
margin={{ top: 5, right: 20, left: -10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(128, 128, 128, 0.2)" />
<XAxis dataKey="date" tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<YAxis
tick={{ fill: '#9CA3AF', fontSize: 12 }}
tickFormatter={(value) => `$${(Number(value) / 100).toFixed(2)}`}
domain={['dataMin', 'auto']}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(31, 41, 55, 0.9)',
border: '1px solid #4B5563',
borderRadius: '0.5rem',
}}
labelStyle={{ color: '#F9FAFB' }}
formatter={(value: number) => `$${(value / 100).toFixed(2)}`}
/>
<Legend wrapperStyle={{fontSize: "12px"}} />
{availableItems.map((item, index) => (
<Line
key={item}
type="monotone"
dataKey={item}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={{ r: 4 }}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
)
}
return (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Historical Price Trends</h3>
<div style={{ width: '100%', height: 300 }}>
{renderContent()}
</div>
</div>
);
};