import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, } from 'recharts'; import type { ArchiveSlim } from '../api/client'; import { MetricToggle, type Metric } from './MetricToggle'; import { parseUTCDate } from '../utils/date'; import { formatWeight } from '../utils/weight'; interface FilamentTrendsProps { archives: ArchiveSlim[]; currency?: string; dateFrom?: string; dateTo?: string; } const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316']; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const HOUR_SUFFIXES = ['12am', '1am', '2am', '3am', '4am', '5am', '6am', '7am', '8am', '9am', '10am', '11am', '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', '6pm', '7pm', '8pm', '9pm', '10pm', '11pm']; export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: FilamentTrendsProps) { const { t } = useTranslation(); const [filamentTypeMetric, setFilamentTypeMetric] = useState('weight'); const [colorMetric, setColorMetric] = useState('weight'); // Calculate daily usage data const dailyData = useMemo(() => { const dataMap = new Map(); archives.forEach(archive => { const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date(); // Use local date string for grouping const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 }; existing.filament += archive.filament_used_grams || 0; existing.cost += archive.cost || 0; existing.prints += archive.quantity || 1; dataMap.set(key, existing); }); return Array.from(dataMap.values()) .sort((a, b) => a.date.localeCompare(b.date)) .map(d => ({ ...d, dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), })); }, [archives]); // Compute effective span in days from props or archive spread const spanDays = useMemo(() => { if (dateFrom && dateTo) { return Math.max((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / 86400000, 0) + 1; } if (dateFrom) { return Math.max((Date.now() - new Date(dateFrom).getTime()) / 86400000, 0) + 1; } if (archives.length < 2) return 0; const times = archives.map(a => new Date(a.completed_at || a.created_at).getTime()); return (Math.max(...times) - Math.min(...times)) / 86400000; }, [archives, dateFrom, dateTo]); // Calculate hourly data for short timeframes (≤ 7 days) const hourlyData = useMemo(() => { if (spanDays > 7) return []; const dataMap = new Map(); const multiDay = spanDays > 1; archives.forEach(archive => { const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date(); const h = date.getHours(); const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}`; const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 }; existing.filament += archive.filament_used_grams || 0; existing.cost += archive.cost || 0; existing.prints += archive.quantity || 1; dataMap.set(key, existing); }); return Array.from(dataMap.values()) .sort((a, b) => a.date.localeCompare(b.date)) .map(d => { const [datePart, hourPart] = d.date.split('T'); const dt = new Date(datePart); const h = parseInt(hourPart, 10); const label = multiDay ? `${DAY_NAMES[dt.getDay()]} ${HOUR_SUFFIXES[h]}` : HOUR_SUFFIXES[h]; return { ...d, dateLabel: label }; }); }, [archives, spanDays]); // Calculate weekly aggregated data when there are many daily points const weeklyData = useMemo(() => { if (dailyData.length <= 60) return dailyData; const dataMap = new Map(); dailyData.forEach(day => { const date = new Date(day.date); const weekStart = new Date(date); weekStart.setDate(date.getDate() - date.getDay()); const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`; const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 }; existing.filament += day.filament; existing.cost += day.cost; existing.prints += day.prints; dataMap.set(key, existing); }); return Array.from(dataMap.values()) .sort((a, b) => a.week.localeCompare(b.week)) .map(d => ({ date: d.week, dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, ...d, })); }, [dailyData]); // Usage by filament type const filamentTypeData = useMemo(() => { const dataMap = new Map(); archives.forEach(archive => { const type = archive.filament_type || 'Unknown'; // Handle multiple types (e.g., "PLA, PETG") const types = type.split(', '); types.forEach(t => { const grams = (archive.filament_used_grams || 0) / types.length; dataMap.set(t, (dataMap.get(t) || 0) + grams); }); }); return Array.from(dataMap.entries()) .map(([name, value]) => ({ name, value: Math.round(value) })) .sort((a, b) => b.value - a.value); }, [archives]); // Usage by filament type (print count) const filamentTypePrintData = useMemo(() => { const dataMap = new Map(); archives.forEach(archive => { const type = archive.filament_type || 'Unknown'; const types = type.split(', '); types.forEach(t => { dataMap.set(t, (dataMap.get(t) || 0) + 1); }); }); return Array.from(dataMap.entries()) .map(([name, value]) => ({ name, value })) .sort((a, b) => b.value - a.value); }, [archives]); // Usage by filament type (print time in hours) const filamentTypeTimeData = useMemo(() => { const dataMap = new Map(); archives.forEach(archive => { const type = archive.filament_type || 'Unknown'; const types = type.split(', '); const seconds = (archive.actual_time_seconds || archive.print_time_seconds || 0) / types.length; types.forEach(t => { dataMap.set(t, (dataMap.get(t) || 0) + seconds); }); }); return Array.from(dataMap.entries()) .map(([name, seconds]) => ({ name, value: Math.round((seconds / 3600) * 10) / 10 })) .sort((a, b) => b.value - a.value); }, [archives]); // Success rate by filament type const filamentSuccessData = useMemo(() => { const map = new Map(); archives.forEach(a => { if (a.status !== 'completed' && a.status !== 'failed') return; const types = (a.filament_type || 'Unknown').split(', '); types.forEach(type => { const entry = map.get(type) || { completed: 0, failed: 0 }; if (a.status === 'completed') entry.completed++; else entry.failed++; map.set(type, entry); }); }); return Array.from(map.entries()) .filter(([, v]) => v.completed + v.failed >= 2) .map(([name, v]) => { const total = v.completed + v.failed; const rate = Math.round((v.completed / total) * 100); return { name, rate, total }; }) .sort((a, b) => b.rate - a.rate); }, [archives]); // Color distribution const colorData = useMemo(() => { const colorMap = new Map(); archives.forEach(a => { if (!a.filament_color) return; const colors = a.filament_color.split(',').map(c => c.trim()); const weightPerColor = (a.filament_used_grams || 0) / colors.length; colors.forEach(hex => { const entry = colorMap.get(hex) || { count: 0, weight: 0 }; entry.count++; entry.weight += weightPerColor; colorMap.set(hex, entry); }); }); return Array.from(colorMap.entries()) .map(([hex, data]) => ({ hex, value: colorMetric === 'prints' ? data.count : Math.round(data.weight), })) .sort((a, b) => b.value - a.value); }, [archives, colorMetric]); const activeFilamentTypeData = filamentTypeMetric === 'weight' ? filamentTypeData : filamentTypeMetric === 'prints' ? filamentTypePrintData : filamentTypeTimeData; const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData; const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0); const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0); const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0); const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size; return (
{/* Summary Cards */}

{t('stats.periodFilament')}

{formatWeight(totalFilament)}

{printerCount} {t('nav.printers').toLowerCase()}

{t('stats.periodCost')}

{currency}{totalCost.toFixed(2)}

{totalPrints} {t('common.prints')}

{t('stats.avgPerPrint')}

{totalPrints > 0 ? (totalFilament / totalPrints).toFixed(0) : 0}g

{currency}{totalPrints > 0 ? (totalCost / totalPrints).toFixed(2) : '0.00'} avg

{/* Usage Over Time Chart */} {chartData.length > 0 ? (

{t('stats.usageOverTime')}

`${value}g`} /> [`${Number(value ?? 0).toFixed(0)}g`, 'Filament']} />
) : (
{t('stats.noPrintDataInRange')}
)} {/* Bottom Charts */}
{/* Filament Type Distribution */}

{t('stats.byMaterial')}

{activeFilamentTypeData.length > 0 ? (
{activeFilamentTypeData.map((_, index) => ( ))} [ filamentTypeMetric === 'weight' ? formatWeight(Number(value ?? 0)) : filamentTypeMetric === 'time' ? `${Number(value ?? 0)}h` : `${value ?? 0}`, filamentTypeMetric === 'weight' ? 'Usage' : filamentTypeMetric === 'time' ? 'Time' : 'Prints', ]} />
{activeFilamentTypeData.map((entry, index) => { const total = activeFilamentTypeData.reduce((sum, e) => sum + e.value, 0); const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0; return (
{entry.name} {filamentTypeMetric === 'weight' ? formatWeight(entry.value) : filamentTypeMetric === 'time' ? `${entry.value}h` : entry.value} · {percent}%
); })}
) : (
{t('stats.noFilamentData')}
)}
{/* Success by Material */}

{t('stats.filamentSuccess')}

{filamentSuccessData.length > 0 ? (
{filamentSuccessData.map(d => (
{d.name}
= 90 ? 'bg-status-ok' : d.rate >= 70 ? 'bg-status-warning' : 'bg-status-error' }`} style={{ width: `${d.rate}%` }} />
= 90 ? 'text-status-ok' : d.rate >= 70 ? 'text-status-warning' : 'text-status-error' }`}> {d.rate}% ({d.total})
))}
) : (
{t('stats.noArchiveData')}
)}
{/* Color Distribution */}

{t('stats.colorDistribution')}

{colorData.length > 0 ? (() => { const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0); return (
{colorData.map((entry, index) => ( ))} [ colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`, colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'), ]} />
{colorMetric === 'weight' ? formatWeight(colorTotal) : colorTotal} {colorData.length} {colorData.length === 1 ? 'color' : 'colors'}
{colorData.slice(0, 8).map((entry) => { const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0; return (
{percent}%
); })}
{colorData.length > 8 && (

+{colorData.length - 8} more

)}
); })() : (
{t('stats.noColorData')}
)}
); }