187 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}; |