Files
flyer-crawler.projectium.com/src/features/charts/PriceHistoryChart.tsx
Torben Sorensen 921c48fc57
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m14s
more unit test fixes now the UseProfileAddress OOM has been identified
2025-12-23 15:50:01 -08:00

234 lines
7.7 KiB
TypeScript

// src/features/charts/PriceHistoryChart.tsx
import React, { useState, useEffect, useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import * as apiClient from '../../services/apiClient';
import { LoadingSpinner } from '../../components/LoadingSpinner'; // This path is correct
import { useUserData } from '../../hooks/useUserData';
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'];
export const PriceHistoryChart: React.FC = () => {
const { watchedItems, isLoading: isLoadingUserData } = useUserData();
const [historicalData, setHistoricalData] = useState<HistoricalData>({});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const watchedItemsMap = useMemo(
() => new Map(watchedItems.map((item) => [item.master_grocery_item_id, item.name])),
[watchedItems],
);
useEffect(() => {
if (watchedItems.length === 0) {
setIsLoading(false);
setHistoricalData({}); // Clear data if watchlist becomes empty
return;
}
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const watchedItemIds = watchedItems
.map((item) => item.master_grocery_item_id)
.filter((id): id is number => id !== undefined); // Ensure only numbers are passed
const response = await apiClient.fetchHistoricalPriceData(watchedItemIds);
const rawData: HistoricalPriceDataPoint[] = await response.json();
if (rawData.length === 0) {
setHistoricalData({});
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
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) {
// This is a type-safe way to handle errors. We check if the caught
// object is an instance of Error before accessing its message property.
setError(e instanceof Error ? 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 || 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}
</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>
);
};