import { useQuery } from '@tanstack/react-query'; import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Package, Clock, CheckCircle, XCircle, DollarSign, Target, Zap, AlertTriangle, TrendingDown, FileSpreadsheet, FileText, Loader2, Eye, RotateCcw, Calculator, Calendar, ChevronDown, } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; import { Button } from '../components/Button'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { api, type Archive } from '../api/client'; import { PrintCalendar } from '../components/PrintCalendar'; import { FilamentTrends } from '../components/FilamentTrends'; import { Dashboard, type DashboardWidget } from '../components/Dashboard'; import { getCurrencySymbol } from '../utils/currency'; import { formatWeight } from '../utils/weight'; import { parseUTCDate, formatDuration } from '../utils/date'; import { MetricToggle, type Metric } from '../components/MetricToggle'; // Timeframe types and helpers type TimeframePreset = 'today' | 'this-week' | 'this-month' | 'last-7' | 'last-30' | 'last-90' | 'this-year' | 'all-time' | 'custom'; interface TimeframeState { preset: TimeframePreset; dateFrom: string | undefined; // YYYY-MM-DD dateTo: string | undefined; // YYYY-MM-DD } function computeDateRange(preset: TimeframePreset): { dateFrom?: string; dateTo?: string } { const now = new Date(); const y = now.getUTCFullYear(), m = now.getUTCMonth(), d = now.getUTCDate(); const fmt = (dt: Date) => dt.toISOString().split('T')[0]; const todayStr = fmt(now); switch (preset) { case 'today': return { dateFrom: todayStr, dateTo: todayStr }; case 'this-week': { const day = now.getUTCDay(); const start = new Date(Date.UTC(y, m, d - (day === 0 ? 6 : day - 1))); return { dateFrom: fmt(start), dateTo: todayStr }; } case 'this-month': return { dateFrom: fmt(new Date(Date.UTC(y, m, 1))), dateTo: todayStr }; case 'last-7': return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 6))), dateTo: todayStr }; case 'last-30': return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 29))), dateTo: todayStr }; case 'last-90': return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 89))), dateTo: todayStr }; case 'this-year': return { dateFrom: fmt(new Date(Date.UTC(y, 0, 1))), dateTo: todayStr }; case 'all-time': return { dateFrom: undefined, dateTo: undefined }; case 'custom': return {}; } } const TIMEFRAME_PRESETS: TimeframePreset[] = [ 'today', 'this-week', 'this-month', 'last-7', 'last-30', 'last-90', 'this-year', 'all-time', ]; // Constants const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const HOUR_LABELS = [ '12am', '1am', '2am', '3am', '4am', '5am', '6am', '7am', '8am', '9am', '10am', '11am', '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', '6pm', '7pm', '8pm', '9pm', '10pm', '11pm', ]; const DURATION_BUCKETS = [ { key: '<30m', max: 1800 }, { key: '30m-1h', max: 3600 }, { key: '1-2h', max: 7200 }, { key: '2-4h', max: 14400 }, { key: '4-8h', max: 28800 }, { key: '8-12h', max: 43200 }, { key: '12-24h', max: 86400 }, { key: '24h+', max: Infinity }, ]; const RECHARTS_TOOLTIP_STYLE = { backgroundColor: '#2d2d2d', border: '1px solid #3d3d3d', borderRadius: '8px', }; // Widget Components function QuickStatsWidget({ stats, currency, }: { stats: { total_prints: number; successful_prints: number; failed_prints: number; total_print_time_hours: number; total_filament_grams: number; total_cost: number; total_energy_kwh: number; total_energy_cost: number; } | undefined; currency: string; }) { const { t } = useTranslation(); return (

{t('stats.totalPrints')}

{stats?.total_prints || 0}

{t('stats.printTime')}

{stats?.total_print_time_hours.toFixed(1) || 0}h

{t('stats.filamentUsed')}

{formatWeight(stats?.total_filament_grams || 0)}

{t('stats.filamentCost')}

{currency} {stats?.total_cost.toFixed(2) || '0.00'}

{t('stats.energyUsed')}

{stats?.total_energy_kwh.toFixed(3) || '0.000'} kWh

{t('stats.energyCost')}

{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}

); } function SuccessRateWidget({ stats, printerMap, size = 1, }: { stats: { total_prints: number; successful_prints: number; failed_prints: number; prints_by_printer: Record; } | undefined; printerMap: Map; size?: 1 | 2 | 4; }) { const { t } = useTranslation(); const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0); const successRate = completedAndFailed ? Math.round((stats!.successful_prints / completedAndFailed) * 100) : 0; // Scale gauge size based on widget size const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144; const radius = gaugeSize / 2 - 8; const circumference = radius * 2 * Math.PI; return (
= 2 ? 'text-2xl' : 'text-xl'}`}>{successRate}%
{t('stats.successful')} {stats?.successful_prints || 0}
{t('stats.failed')} {stats?.failed_prints || 0}
{/* Show per-printer breakdown when expanded */} {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (

{t('stats.printsByPrinter')}

{Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`} {count}
))}
)}
); } function TimeAccuracyWidget({ stats, printerMap, size = 1, }: { stats: { average_time_accuracy: number | null; time_accuracy_by_printer: Record | null; } | undefined; printerMap: Map; size?: 1 | 2 | 4; }) { const { t } = useTranslation(); const accuracy = stats?.average_time_accuracy; if (accuracy === null || accuracy === undefined) { return (

{t('stats.noTimeAccuracyData')}

); } // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge) const displayValue = Math.min(150, Math.max(50, accuracy)); const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100 // Color based on accuracy const getColor = (acc: number) => { if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5% if (acc > 105) return '#3b82f6'; // Blue - faster than expected return '#f97316'; // Orange - slower than expected }; const color = getColor(accuracy); const deviation = accuracy - 100; // Scale gauge size based on widget size const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144; const radius = gaugeSize / 2 - 8; const circumference = radius * 2 * Math.PI; // Show more printers when expanded const maxPrinters = size === 1 ? 3 : size === 2 ? 6 : 999; const printerEntries = stats?.time_accuracy_by_printer ? Object.entries(stats.time_accuracy_by_printer).slice(0, maxPrinters) : []; return (
= 2 ? 'text-2xl' : 'text-xl'}`}>{accuracy.toFixed(0)}% = 0 ? 'text-blue-400' : 'text-orange-400'}`}> {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
{t('stats.perfectEstimate')}
{printerEntries.length > 0 && (
{printerEntries.map(([printerId, acc]) => (
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`} = 95 && acc <= 105 ? 'text-status-ok' : acc > 105 ? 'text-blue-400' : 'text-status-warning' }`}> {acc.toFixed(0)}%
))}
)}
); } function PrintActivityWidget({ printDates, size = 2, }: { printDates: string[]; size?: 1 | 2 | 4; }) { // Show more months when widget is larger - cell size auto-calculated const months = size === 1 ? 3 : size === 2 ? 6 : 12; return ; } function PrinterStatsWidget({ stats, archives, printerMap, }: { stats: { prints_by_printer: Record } | undefined; archives: Archive[]; printerMap: Map; }) { const { t } = useTranslation(); const [printerMetric, setPrinterMetric] = useState('weight'); const [habitsMetric, setHabitsMetric] = useState('weight'); // Per-printer data const printerData = useMemo(() => { const map = new Map(); if (stats?.prints_by_printer) { Object.entries(stats.prints_by_printer).forEach(([id, count]) => { const entry = map.get(id) || { prints: 0, weight: 0, time: 0 }; entry.prints = count; map.set(id, entry); }); } archives.forEach(a => { if (!a.printer_id) return; const id = String(a.printer_id); const entry = map.get(id) || { prints: 0, weight: 0, time: 0 }; entry.weight += a.filament_used_grams || 0; entry.time += a.actual_time_seconds || a.print_time_seconds || 0; if (!stats?.prints_by_printer) entry.prints++; map.set(id, entry); }); return Array.from(map.entries()) .map(([id, v]) => ({ name: printerMap.get(id) || `${t('common.printer')} ${id}`, value: printerMetric === 'prints' ? v.prints : printerMetric === 'weight' ? Math.round(v.weight) : Math.round((v.time / 3600) * 10) / 10, })) .sort((a, b) => b.value - a.value); }, [stats, archives, printerMap, printerMetric, t]); // Hourly distribution (time of day) const hourlyData = useMemo(() => { const hours = Array.from({ length: 24 }, (_, i) => ({ hour: i, label: HOUR_LABELS[i], total: 0, failures: 0, })); archives.forEach(a => { if (!a.started_at) return; const date = parseUTCDate(a.started_at); if (!date) return; const h = date.getHours(); hours[h].total++; if (a.status === 'failed') { hours[h].failures++; } }); return hours; }, [archives]); // Duration distribution const durationData = useMemo(() => { const counts = DURATION_BUCKETS.map(b => ({ name: b.key, count: 0 })); archives.forEach(a => { const seconds = a.actual_time_seconds || a.print_time_seconds; if (!seconds || seconds <= 0) return; for (let i = 0; i < DURATION_BUCKETS.length; i++) { if (seconds <= DURATION_BUCKETS[i].max) { counts[i].count++; break; } } }); return counts; }, [archives]); // Habits (avg per day-of-week) const habitsData = useMemo(() => { const dayValues = [0, 0, 0, 0, 0, 0, 0]; const weeksSet = new Set(); archives.forEach(a => { const date = parseUTCDate(a.created_at) || new Date(a.created_at); let day = date.getDay() - 1; if (day < 0) day = 6; if (habitsMetric === 'prints') dayValues[day]++; else if (habitsMetric === 'weight') dayValues[day] += a.filament_used_grams || 0; else dayValues[day] += (a.actual_time_seconds || a.print_time_seconds || 0) / 3600; const weekStart = new Date(date); weekStart.setDate(date.getDate() - ((date.getDay() + 6) % 7)); weeksSet.add(weekStart.toISOString().split('T')[0]); }); const numWeeks = Math.max(weeksSet.size, 1); return DAY_LABELS.map((name, i) => ({ name, avg: Math.round((dayValues[i] / numWeeks) * 10) / 10, })); }, [archives, habitsMetric]); const pUnit = printerMetric === 'weight' ? 'g' : printerMetric === 'time' ? 'h' : ''; const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints'); const pColor = printerMetric === 'weight' ? '#00ae42' : printerMetric === 'time' ? '#3b82f6' : '#f59e0b'; const hUnit = habitsMetric === 'weight' ? 'g' : habitsMetric === 'time' ? 'h' : ''; const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints'); const hColor = habitsMetric === 'weight' ? '#00ae42' : habitsMetric === 'time' ? '#3b82f6' : '#f59e0b'; return (
{/* By Printer */}

{t('stats.printsByPrinter')}

{printerData.length > 0 ? ( [ printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${pUnit}`, pLabel, ]} /> ) : (

{t('stats.noPrinterData')}

)}
{/* Print Duration */}

{t('stats.printDuration')}

{archives.length > 0 ? ( ) : (

{t('stats.noArchiveData')}

)}
{/* Print Habits */}

{t('stats.printHabits')}

{archives.length > 0 ? ( [`${v ?? 0}${hUnit}`, hLabel]} /> ) : (

{t('stats.noArchiveData')}

)}
{/* Print Time of Day */}

{t('stats.printTimeOfDay')}

{archives.length > 0 ? ( ) : (

{t('stats.noArchiveData')}

)}
); } function FilamentTrendsWidget({ archives, currency, }: { archives: Parameters[0]['archives']; currency: string; }) { const { t } = useTranslation(); if (!archives || archives.length === 0) { return

{t('stats.noPrintData')}

; } return ; } function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: { size?: 1 | 2 | 4; dateFrom?: string; dateTo?: string; }) { const { t } = useTranslation(); const hasDateRange = !!(dateFrom || dateTo); const { data: analysis, isLoading } = useQuery({ queryKey: ['failureAnalysis', dateFrom, dateTo], queryFn: () => api.getFailureAnalysis({ ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }), }), }); if (isLoading) { return (
); } if (!analysis || analysis.total_prints === 0) { return

{hasDateRange ? t('stats.noPrintDataInRange') : t('stats.noPrintDataLast30Days')}

; } // Show more reasons when expanded const maxReasons = size === 1 ? 5 : size === 2 ? 8 : 999; const allReasons = Object.entries(analysis.failures_by_reason).sort(([, a], [, b]) => b - a); const topReasons = allReasons.slice(0, maxReasons); const hasMore = allReasons.length > maxReasons; return (
= 2 ? 'flex gap-8' : 'space-y-4'}`}> {/* Summary */}
= 2 ? 'flex-shrink-0' : ''}>
20 ? 'text-status-error' : analysis.failure_rate > 10 ? 'text-status-warning' : 'text-status-ok'}`} /> = 2 ? 'text-3xl' : 'text-2xl'}`}>{analysis.failure_rate.toFixed(1)}%
{t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
{/* Trend indicator */} {analysis.trend && analysis.trend.length >= 2 && (
= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>
{t('stats.lastWeekRate', { rate: analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1) })}
)}
{/* Failure Reasons */} {topReasons.length > 0 && (
= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>

{size >= 2 ? t('stats.failureReasons') : t('stats.topFailureReasons')}

{topReasons.map(([reason, count]) => (
{reason || t('common.unknown')} {count}
))}
{hasMore && (

{t('common.more', { count: allReasons.length - maxReasons })}

)}
)}
); } function RecordsWidget({ archives, currency }: { archives: Archive[]; currency: string }) { const { t } = useTranslation(); const records = useMemo(() => { const result: Array<{ icon: typeof Clock; iconColor: string; label: string; value: string; detail: string | null; }> = []; if (archives.length === 0) return result; // Longest print let longestArchive: Archive | null = null; let longestTime = 0; archives.forEach(a => { if (a.actual_time_seconds && a.actual_time_seconds > longestTime) { longestTime = a.actual_time_seconds; longestArchive = a; } }); if (longestArchive && longestTime > 0) { result.push({ icon: Clock, iconColor: 'text-blue-400', label: t('stats.longestPrint'), value: formatDuration(longestTime), detail: (longestArchive as Archive).print_name || null, }); } // Heaviest print let heaviestArchive: Archive | null = null; let heaviestWeight = 0; archives.forEach(a => { if (a.filament_used_grams && a.filament_used_grams > heaviestWeight) { heaviestWeight = a.filament_used_grams; heaviestArchive = a; } }); if (heaviestArchive && heaviestWeight > 0) { result.push({ icon: Package, iconColor: 'text-orange-400', label: t('stats.heaviestPrint'), value: formatWeight(heaviestWeight), detail: (heaviestArchive as Archive).print_name || null, }); } // Most expensive print let costliestArchive: Archive | null = null; let highestCost = 0; archives.forEach(a => { if (a.cost && a.cost > highestCost) { highestCost = a.cost; costliestArchive = a; } }); if (costliestArchive && highestCost > 0) { result.push({ icon: DollarSign, iconColor: 'text-green-400', label: t('stats.mostExpensivePrint'), value: `${currency}${highestCost.toFixed(2)}`, detail: (costliestArchive as Archive).print_name || null, }); } // Busiest day const dayCounts = new Map(); archives.forEach(a => { const date = parseUTCDate(a.created_at) || new Date(a.created_at); const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; dayCounts.set(key, (dayCounts.get(key) || 0) + 1); }); let busiestDay = ''; let busiestCount = 0; dayCounts.forEach((count, day) => { if (count > busiestCount) { busiestCount = count; busiestDay = day; } }); if (busiestCount > 1) { result.push({ icon: Calendar, iconColor: 'text-purple-400', label: t('stats.busiestDay'), value: `${busiestCount} ${t('common.prints')}`, detail: new Date(busiestDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }), }); } // Success streak const sorted = [...archives] .filter(a => a.status === 'completed' || a.status === 'failed') .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); let streak = 0; for (const a of sorted) { if (a.status === 'completed') streak++; else break; } if (streak > 0) { result.push({ icon: Zap, iconColor: 'text-yellow-400', label: t('stats.successStreak'), value: `${streak}`, detail: streak === 1 ? t('stats.streakPrint') : t('stats.streakPrints', { count: streak }), }); } return result; }, [archives, currency, t]); if (records.length === 0) { return

{t('stats.noArchiveData')}

; } return (
{records.map((record, i) => (

{record.label}

{record.value} {record.detail && ( {record.detail} )}
))}
); } export function StatsPage() { const { t } = useTranslation(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const [isExporting, setIsExporting] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false); const [dashboardKey, setDashboardKey] = useState(0); const [hiddenCount, setHiddenCount] = useState(0); const [isRecalculating, setIsRecalculating] = useState(false); const [timeframe, setTimeframe] = useState({ preset: 'all-time', dateFrom: undefined, dateTo: undefined, }); const [showTimeframePicker, setShowTimeframePicker] = useState(false); const effectiveDateRange = useMemo(() => { if (timeframe.preset === 'custom') { return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo }; } return computeDateRange(timeframe.preset); }, [timeframe]); // Read hidden count from localStorage useEffect(() => { const updateHiddenCount = () => { try { const saved = localStorage.getItem('bambusy-dashboard-layout-v2'); if (saved) { const layout = JSON.parse(saved); setHiddenCount(layout.hidden?.length || 0); } } catch { setHiddenCount(0); } }; updateHiddenCount(); // Listen for storage changes window.addEventListener('storage', updateHiddenCount); // Also poll for changes (since storage event doesn't fire for same-tab changes) const interval = setInterval(updateHiddenCount, 2000); return () => { window.removeEventListener('storage', updateHiddenCount); clearInterval(interval); }; }, [dashboardKey]); const { data: stats, isLoading, refetch: refetchStats } = useQuery({ queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo], queryFn: () => api.getArchiveStats({ dateFrom: effectiveDateRange.dateFrom, dateTo: effectiveDateRange.dateTo, }), }); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); const { data: archives, refetch: refetchArchives } = useQuery({ queryKey: ['archives', effectiveDateRange.dateFrom, effectiveDateRange.dateTo], queryFn: () => api.getArchives(undefined, undefined, 10000, 0, effectiveDateRange.dateFrom, effectiveDateRange.dateTo), }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const handleExport = async (format: 'csv' | 'xlsx') => { setShowExportMenu(false); setIsExporting(true); try { const { blob, filename } = await api.exportStats({ format, days: 90 }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); showToast(t('stats.exportDownloaded')); } catch { showToast(t('stats.exportFailed'), 'error'); } finally { setIsExporting(false); } }; const handleRecalculateCosts = async () => { setIsRecalculating(true); try { const result = await api.recalculateCosts(); await Promise.all([refetchStats(), refetchArchives()]); showToast(t('stats.recalculatedCosts', { count: result.updated })); } catch { showToast(t('stats.recalculateFailed'), 'error'); } finally { setIsRecalculating(false); } }; const currency = getCurrencySymbol(settings?.currency || 'USD'); const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []); const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]); if (isLoading) { return (
{t('stats.loadingStats')}
); } // Define dashboard widgets // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width // Widgets can use render functions to receive the current size for responsive content const widgets: DashboardWidget[] = [ { id: 'quick-stats', title: t('stats.quickStats'), component: , defaultSize: 2, }, { id: 'success-rate', title: t('stats.successRate'), component: (size) => , defaultSize: 1, }, { id: 'time-accuracy', title: t('stats.timeAccuracy'), component: (size) => , defaultSize: 1, }, { id: 'failure-analysis', title: t('stats.failureAnalysis'), component: (size) => , defaultSize: 1, }, { id: 'print-activity', title: t('stats.printActivity'), component: (size) => , defaultSize: 2, }, { id: 'records', title: t('stats.records'), component: , defaultSize: 1, }, { id: 'printer-stats', title: t('stats.printerStats'), component: , defaultSize: 4, }, { id: 'filament-trends', title: t('stats.filamentTrends'), component: , defaultSize: 4, }, ]; return (

{t('stats.title')}

{t('stats.subtitle')}

{/* Hidden widgets button - toggles panel in Dashboard */} {hiddenCount > 0 && ( )} {/* Reset Layout */} {/* Recalculate Costs */} {/* Export dropdown */}
{showExportMenu && (
)}
{/* Timeframe Selector */}
{showTimeframePicker && ( <>
setShowTimeframePicker(false)} />
{TIMEFRAME_PRESETS.map((preset) => ( ))}
{timeframe.preset === 'custom' && (
setTimeframe(prev => ({ ...prev, dateFrom: e.target.value || undefined }))} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]" />
setTimeframe(prev => ({ ...prev, dateTo: e.target.value || undefined }))} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]" />
)}
)}
); }