diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 6eab0d72f..9ae28b326 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -158,7 +158,7 @@ async def list_archives( async def list_archives_slim( date_from: date | None = Query(None), date_to: date | None = Query(None), - limit: int = 10000, + limit: int = Query(default=10000, le=50000), offset: int = 0, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ), diff --git a/frontend/src/__tests__/pages/StatsPage.test.tsx b/frontend/src/__tests__/pages/StatsPage.test.tsx index 2c9800f1a..1a86462e0 100644 --- a/frontend/src/__tests__/pages/StatsPage.test.tsx +++ b/frontend/src/__tests__/pages/StatsPage.test.tsx @@ -133,8 +133,8 @@ const mockFailureAnalysis = { failures_by_hour: {}, recent_failures: [], trend: [ - { week: '2024-W01', failure_rate: 6.0 }, - { week: '2024-W02', failure_rate: 5.0 }, + { week_start: '2024-01-01', total_prints: 50, failed_prints: 3, failure_rate: 6.0 }, + { week_start: '2024-01-08', total_prints: 50, failed_prints: 2, failure_rate: 5.0 }, ], }; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0a712750c..6e9a67b9d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2519,7 +2519,8 @@ export const api = { const params = new URLSearchParams(); if (dateFrom) params.set('date_from', dateFrom); if (dateTo) params.set('date_to', dateTo); - return request(`/archives/slim?${params}`); + const qs = params.toString(); + return request(`/archives/slim${qs ? `?${qs}` : ''}`); }, getArchive: (id: number) => request(`/archives/${id}`), searchArchives: (query: string, options?: { @@ -2587,7 +2588,8 @@ export const api = { if (options?.dateTo) params.set('date_to', options.dateTo); if (options?.printerId) params.set('printer_id', String(options.printerId)); if (options?.projectId) params.set('project_id', String(options.projectId)); - return request(`/archives/analysis/failures?${params}`); + const qs = params.toString(); + return request(`/archives/analysis/failures${qs ? `?${qs}` : ''}`); }, compareArchives: (archiveIds: number[]) => request(`/archives/compare?archive_ids=${archiveIds.join(',')}`), diff --git a/frontend/src/components/FilamentTrends.tsx b/frontend/src/components/FilamentTrends.tsx index 1de8babd0..694f0ddfe 100644 --- a/frontend/src/components/FilamentTrends.tsx +++ b/frontend/src/components/FilamentTrends.tsx @@ -20,11 +20,16 @@ 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']; -export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) { +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'); @@ -53,6 +58,51 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps })); }, [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; @@ -184,7 +234,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps filamentTypeMetric === 'prints' ? filamentTypePrintData : filamentTypeTimeData; - const chartData = weeklyData; + 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); diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index f063c9c5b..b7712149a 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -135,11 +135,11 @@ function QuickStatsWidget({ const items = [ { icon: Package, color: 'text-bambu-green', label: t('stats.totalPrints'), value: `${stats?.total_prints || 0}` }, - { icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours.toFixed(1) || 0}h` }, + { icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours?.toFixed(1) ?? '0'}h` }, { icon: Package, color: 'text-orange-400', label: t('stats.filamentUsed'), value: formatWeight(stats?.total_filament_grams || 0) }, - { icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost.toFixed(2) || '0.00'}` }, - { icon: Zap, color: 'text-yellow-400', label: t('stats.energyUsed'), value: `${stats?.total_energy_kwh.toFixed(3) || '0.000'} kWh` }, - { icon: DollarSign, color: 'text-yellow-500', label: t('stats.energyCost'), value: `${currency} ${stats?.total_energy_cost.toFixed(2) || '0.00'}` }, + { icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost?.toFixed(2) ?? '0.00'}` }, + { icon: Zap, color: 'text-yellow-400', label: t('stats.energyUsed'), value: `${stats?.total_energy_kwh?.toFixed(3) ?? '0.000'} kWh` }, + { icon: DollarSign, color: 'text-yellow-500', label: t('stats.energyCost'), value: `${currency} ${stats?.total_energy_cost?.toFixed(2) ?? '0.00'}` }, ]; return ( @@ -350,15 +350,137 @@ function TimeAccuracyWidget({ ); } +function HourlyHeatmap({ printDates, dateFrom, dateTo }: { printDates: string[]; dateFrom: string; dateTo: string }) { + const { days, hourlyCounts, maxCount } = useMemo(() => { + const start = new Date(dateFrom + 'T00:00:00'); + const end = new Date(dateTo + 'T00:00:00'); + const days: { key: string; label: string }[] = []; + const fmtLocal = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const current = new Date(start); + while (current <= end) { + days.push({ + key: fmtLocal(current), + label: current.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }), + }); + current.setDate(current.getDate() + 1); + } + + // Count prints per (day, hour) + const counts: Record = {}; + let max = 0; + printDates.forEach(d => { + const date = parseUTCDate(d); + if (!date) return; + const dayKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const k = `${dayKey}-${date.getHours()}`; + counts[k] = (counts[k] || 0) + 1; + if (counts[k] > max) max = counts[k]; + }); + + return { days, hourlyCounts: counts, maxCount: Math.max(1, max) }; + }, [printDates, dateFrom, dateTo]); + + const getColor = (count: number) => { + if (count === 0) return 'bg-bambu-dark'; + const intensity = count / maxCount; + if (intensity <= 0.25) return 'bg-bambu-green/30'; + if (intensity <= 0.5) return 'bg-bambu-green/50'; + if (intensity <= 0.75) return 'bg-bambu-green/75'; + return 'bg-bambu-green'; + }; + + const cellSize = 20; + const gap = 2; + + const dayLabelWidth = 80; + + return ( +
+
+ {/* Hour labels row */} +
+ {HOUR_LABELS.map((label, i) => ( +
+ {label} +
+ ))} +
+ + {/* Day rows */} + {days.map(day => ( +
+
+ {day.label} +
+ {Array.from({ length: 24 }, (_, hour) => { + const count = hourlyCounts[`${day.key}-${hour}`] || 0; + return ( +
+ ); + })} +
+ ))} +
+ + {/* Legend */} +
+ Less +
+
+
+
+
+
+
+ More +
+
+ ); +} + function PrintActivityWidget({ printDates, size = 2, + dateFrom, + dateTo, }: { printDates: string[]; size?: 1 | 2 | 4; + dateFrom?: string; + dateTo?: string; }) { - // Show more months when widget is larger - cell size auto-calculated - const months = size === 1 ? 3 : size === 2 ? 6 : 12; + 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; + } + return Infinity; + }, [dateFrom, dateTo]); + + if (spanDays <= 7 && dateFrom && dateTo) { + return ; + } + + // Calculate months from the timeframe span, fall back to size-based default for all-time + const sizeDefault = size === 1 ? 3 : size === 2 ? 6 : 12; + const months = spanDays === Infinity + ? sizeDefault + : Math.max(1, Math.ceil(spanDays / 30)); return ; } @@ -456,7 +578,7 @@ function PrinterStatsWidget({ 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]); + weeksSet.add(`${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`); }); const numWeeks = Math.max(weeksSet.size, 1); return DAY_LABELS.map((name, i) => ({ @@ -569,15 +691,19 @@ function PrinterStatsWidget({ function FilamentTrendsWidget({ archives, currency, + dateFrom, + dateTo, }: { archives: Parameters[0]['archives']; currency: string; + dateFrom?: string; + dateTo?: string; }) { const { t } = useTranslation(); if (!archives || archives.length === 0) { return

{t('stats.noPrintData')}

; } - return ; + return ; } function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: { @@ -803,13 +929,23 @@ export function StatsPage() { 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 [timeframe, setTimeframe] = useState(() => { + try { + const saved = localStorage.getItem('bambusy-stats-timeframe'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.preset) return parsed; + } + } catch { /* ignore */ } + return { preset: 'all-time', dateFrom: undefined, dateTo: undefined }; }); const [showTimeframePicker, setShowTimeframePicker] = useState(false); + // Persist timeframe selection + useEffect(() => { + localStorage.setItem('bambusy-stats-timeframe', JSON.stringify(timeframe)); + }, [timeframe]); + const effectiveDateRange = useMemo(() => { if (timeframe.preset === 'custom') { return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo }; @@ -939,7 +1075,7 @@ export function StatsPage() { { id: 'print-activity', title: t('stats.printActivity'), - component: (size) => , + component: (size) => , defaultSize: 2, }, { @@ -957,7 +1093,7 @@ export function StatsPage() { { id: 'filament-trends', title: t('stats.filamentTrends'), - component: , + component: , defaultSize: 4, }, ];