Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m1s
213 lines
6.8 KiB
TypeScript
213 lines
6.8 KiB
TypeScript
// src/features/charts/PriceHistoryChart.tsx
|
|
import React, { useMemo } from 'react';
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
|
import { useUserData } from '../../hooks/useUserData';
|
|
import { usePriceHistoryQuery } from '../../hooks/queries/usePriceHistoryQuery';
|
|
import type { HistoricalPriceDataPoint } from '../../types';
|
|
|
|
type HistoricalData = Record<string, { date: string; price: number }[]>;
|
|
type ChartData = { date: string; [itemName: string]: number | string };
|
|
|
|
const COLORS = ['#10B981', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
|
|
|
|
/**
|
|
* Chart component displaying historical price trends for watched items.
|
|
*
|
|
* Refactored to use TanStack Query (ADR-0005 Phase 8).
|
|
*/
|
|
export const PriceHistoryChart: React.FC = () => {
|
|
const { watchedItems, isLoading: isLoadingUserData } = useUserData();
|
|
|
|
const watchedItemsMap = useMemo(
|
|
() => new Map(watchedItems.map((item) => [item.master_grocery_item_id, item.name])),
|
|
[watchedItems],
|
|
);
|
|
|
|
const watchedItemIds = useMemo(
|
|
() =>
|
|
watchedItems
|
|
.map((item) => item.master_grocery_item_id)
|
|
.filter((id): id is number => id !== undefined),
|
|
[watchedItems],
|
|
);
|
|
|
|
const {
|
|
data: rawData = [],
|
|
isLoading,
|
|
error,
|
|
} = usePriceHistoryQuery(watchedItemIds, watchedItemIds.length > 0);
|
|
|
|
// Process raw data into chart-friendly format
|
|
const historicalData = useMemo<HistoricalData>(() => {
|
|
if (rawData.length === 0) return {};
|
|
|
|
const processedData = rawData.reduce<HistoricalData>(
|
|
(acc, record: HistoricalPriceDataPoint) => {
|
|
if (!record.master_item_id || record.avg_price_in_cents === null || !record.summary_date)
|
|
return acc;
|
|
|
|
const itemName = watchedItemsMap.get(record.master_item_id);
|
|
if (!itemName) return acc;
|
|
|
|
const priceInCents = record.avg_price_in_cents;
|
|
const date = new Date(`${record.summary_date}T00:00:00`).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
|
|
return 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;
|
|
}, {});
|
|
}, [rawData, 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 || isLoadingUserData) {
|
|
return (
|
|
<div role="status" className="flex justify-center items-center h-full min-h-50]">
|
|
<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.message}
|
|
</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={[(a: number) => Math.floor(a * 0.95), (b: number) => Math.ceil(b * 1.05)]}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: 'rgba(31, 41, 55, 0.9)',
|
|
border: '1px solid #4B5563',
|
|
borderRadius: '0.5rem',
|
|
}}
|
|
labelStyle={{ color: '#F9FAFB' }}
|
|
formatter={(value: number | undefined) => {
|
|
if (typeof value === 'number') {
|
|
return [`$${(value / 100).toFixed(2)}`];
|
|
}
|
|
return [null];
|
|
}}
|
|
/>
|
|
<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>
|
|
);
|
|
};
|