Browse Source

feat(stats): add new widgets, shared MetricToggle, and timeframe filtering

Add Print Time of Day, Color Distribution, and Records widgets to the
Stats page. Extract shared MetricToggle component for consistent
weight/prints/time toggles. Add date range filtering to archives and
stats API endpoints. Reorganize widgets into parent cards (Printer Stats
and Filament Trends) and fix Dashboard layout persistence for new widgets.
AneoPsy 2 months ago
parent
commit
792a86f1e5

+ 42 - 12
backend/app/api/routes/archives.py

@@ -2,6 +2,7 @@ import io
 import json
 import logging
 import zipfile
+from datetime import date, datetime, time, timezone
 from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 
@@ -122,6 +123,8 @@ def archive_to_response(
 async def list_archives(
     printer_id: int | None = None,
     project_id: int | None = None,
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
@@ -132,6 +135,8 @@ async def list_archives(
     archives = await service.list_archives(
         printer_id=printer_id,
         project_id=project_id,
+        date_from=date_from,
+        date_to=date_to,
         limit=limit,
         offset=offset,
     )
@@ -277,7 +282,9 @@ async def rebuild_search_index(
 
 @router.get("/analysis/failures")
 async def analyze_failures(
-    days: int = 30,
+    days: int | None = None,
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
     printer_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
@@ -297,6 +304,8 @@ async def analyze_failures(
     service = FailureAnalysisService(db)
     return await service.analyze_failures(
         days=days,
+        date_from=date_from,
+        date_to=date_to,
         printer_id=printer_id,
         project_id=project_id,
     )
@@ -440,25 +449,42 @@ async def export_stats(
 
 @router.get("/stats", response_model=ArchiveStats)
 async def get_archive_stats(
+    date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
+    date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
     """Get statistics across all archives."""
+    # Build date filter conditions
+    base_conditions = []
+    if date_from:
+        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        base_conditions.append(PrintArchive.created_at >= dt_from)
+    if date_to:
+        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        base_conditions.append(PrintArchive.created_at <= dt_to)
+
     # Total counts
-    total_result = await db.execute(select(func.count(PrintArchive.id)))
+    total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
     total_prints = total_result.scalar() or 0
 
-    successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
+    successful_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
+    )
     successful_prints = successful_result.scalar() or 0
 
-    failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
+    failed_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
+    )
     failed_prints = failed_result.scalar() or 0
 
     # Totals - use actual print time from timestamps (not slicer estimates)
     # For archives with both started_at and completed_at, calculate actual duration
     # Fall back to print_time_seconds only for archives without timestamps
     archives_for_time = await db.execute(
-        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds)
+        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
+            *base_conditions
+        )
     )
     total_seconds = 0
     for started_at, completed_at, print_time_seconds in archives_for_time.all():
@@ -473,15 +499,17 @@ async def get_archive_stats(
     total_time = total_seconds / 3600  # Convert to hours
 
     # Sum filament directly - filament_used_grams already contains the total for the print job
-    filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    filament_result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
+    )
     total_filament = filament_result.scalar() or 0
 
-    cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
+    cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
     total_cost = cost_result.scalar() or 0
 
     # By filament type (split comma-separated values for multi-material prints)
     filament_type_result = await db.execute(
-        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None))
+        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
     )
     prints_by_filament: dict[str, int] = {}
     for (filament_types,) in filament_type_result.all():
@@ -493,7 +521,9 @@ async def get_archive_stats(
 
     # By printer
     printer_result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
+        select(PrintArchive.printer_id, func.count(PrintArchive.id))
+        .where(*base_conditions)
+        .group_by(PrintArchive.printer_id)
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
@@ -501,7 +531,7 @@ async def get_archive_stats(
     # Get all completed archives with both estimated and actual times
     accuracy_result = await db.execute(
         select(PrintArchive)
-        .where(PrintArchive.status == "completed")
+        .where(PrintArchive.status == "completed", *base_conditions)
         .where(PrintArchive.print_time_seconds.isnot(None))
         .where(PrintArchive.started_at.isnot(None))
         .where(PrintArchive.completed_at.isnot(None))
@@ -575,10 +605,10 @@ async def get_archive_stats(
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
     else:
         # Print mode: sum up per-print energy from archives
-        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)))
+        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
         total_energy_kwh = energy_kwh_result.scalar() or 0
 
-        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)))
+        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
         total_energy_cost = energy_cost_result.scalar() or 0
 
     return ArchiveStats(

+ 11 - 1
backend/app/services/archive.py

@@ -4,7 +4,7 @@ import logging
 import re
 import shutil
 import zipfile
-from datetime import datetime, timezone
+from datetime import date, datetime, time, timezone
 from pathlib import Path
 
 from defusedxml import ElementTree as ET
@@ -996,6 +996,8 @@ class ArchiveService:
         self,
         printer_id: int | None = None,
         project_id: int | None = None,
+        date_from: date | None = None,
+        date_to: date | None = None,
         limit: int = 50,
         offset: int = 0,
     ) -> list[PrintArchive]:
@@ -1014,6 +1016,14 @@ class ArchiveService:
         if project_id:
             query = query.where(PrintArchive.project_id == project_id)
 
+        if date_from:
+            dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+            query = query.where(PrintArchive.created_at >= dt_from)
+
+        if date_to:
+            dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+            query = query.where(PrintArchive.created_at <= dt_to)
+
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         return list(result.scalars().all())

+ 19 - 6
backend/app/services/failure_analysis.py

@@ -1,5 +1,5 @@
 from collections import defaultdict
-from datetime import datetime, timedelta, timezone
+from datetime import date, datetime, time, timedelta, timezone
 
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,24 +16,37 @@ class FailureAnalysisService:
 
     async def analyze_failures(
         self,
-        days: int = 30,
+        days: int | None = None,
+        date_from: date | None = None,
+        date_to: date | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
     ) -> dict:
         """Analyze failure patterns across archives.
 
         Args:
-            days: Number of days to analyze
+            days: Number of days to analyze (fallback when no date range)
+            date_from: Start date filter (inclusive)
+            date_to: End date filter (inclusive)
             printer_id: Optional filter by printer
             project_id: Optional filter by project
 
         Returns:
             Dictionary with failure analysis results
         """
-        cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
-
         # Build base query
-        base_filter = [PrintArchive.created_at >= cutoff_date]
+        base_filter = []
+        if date_from or date_to:
+            if date_from:
+                dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+                base_filter.append(PrintArchive.created_at >= dt_from)
+            if date_to:
+                dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+                base_filter.append(PrintArchive.created_at <= dt_to)
+        else:
+            effective_days = days if days is not None else 30
+            cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
+            base_filter.append(PrintArchive.created_at >= cutoff_date)
         if printer_id:
             base_filter.append(PrintArchive.printer_id == printer_id)
         if project_id:

+ 13 - 3
frontend/src/api/client.ts

@@ -2489,12 +2489,14 @@ export const api = {
     request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
 
   // Archives
-  getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => {
+  getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0, dateFrom?: string, dateTo?: string) => {
     const params = new URLSearchParams();
     if (printerId) params.set('printer_id', String(printerId));
     if (projectId) params.set('project_id', String(projectId));
     params.set('limit', String(limit));
     params.set('offset', String(offset));
+    if (dateFrom) params.set('date_from', dateFrom);
+    if (dateTo) params.set('date_to', dateTo);
     return request<Archive[]>(`/archives/?${params}`);
   },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
@@ -2536,7 +2538,13 @@ export const api = {
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
-  getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  getArchiveStats: (options?: { dateFrom?: string; dateTo?: string }) => {
+    const params = new URLSearchParams();
+    if (options?.dateFrom) params.set('date_from', options.dateFrom);
+    if (options?.dateTo) params.set('date_to', options.dateTo);
+    const qs = params.toString();
+    return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);
+  },
   // Tag management
   getTags: () => request<TagInfo[]>('/archives/tags'),
   renameTag: (oldName: string, newName: string) =>
@@ -2550,9 +2558,11 @@ export const api = {
     }),
   recalculateCosts: () =>
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
-  getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
+  getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number }) => {
     const params = new URLSearchParams();
     if (options?.days) params.set('days', String(options.days));
+    if (options?.dateFrom) params.set('date_from', options.dateFrom);
+    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<FailureAnalysis>(`/archives/analysis/failures?${params}`);

+ 8 - 0
frontend/src/components/Dashboard.tsx

@@ -157,6 +157,14 @@ export function Dashboard({ widgets, storageKey, columns = 4, stackBelow, hideCo
         // Ensure sizes exist (for backwards compatibility)
         if (!parsed.sizes) {
           parsed.sizes = getDefaultSizes();
+        } else {
+          // Merge in default sizes for any new widgets not in saved layout
+          const defaults = getDefaultSizes();
+          for (const id in defaults) {
+            if (!(id in parsed.sizes)) {
+              parsed.sizes[id] = defaults[id];
+            }
+          }
         }
         return parsed;
       } catch {

+ 237 - 129
frontend/src/components/FilamentTrends.tsx

@@ -1,4 +1,5 @@
 import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
 import {
   AreaChart,
   Area,
@@ -7,55 +8,32 @@ import {
   CartesianGrid,
   Tooltip,
   ResponsiveContainer,
-  BarChart,
-  Bar,
   PieChart,
   Pie,
   Cell,
-  Legend,
 } from 'recharts';
 import type { Archive } from '../api/client';
+import { MetricToggle, type Metric } from './MetricToggle';
 import { parseUTCDate } from '../utils/date';
+import { formatWeight } from '../utils/weight';
 
 interface FilamentTrendsProps {
   archives: Archive[];
   currency?: string;
 }
 
-type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
-
 const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
 
-function getDateRange(range: TimeRange): Date {
-  const now = new Date();
-  switch (range) {
-    case '7d':
-      return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
-    case '30d':
-      return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
-    case '90d':
-      return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
-    case '365d':
-      return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
-    case 'all':
-      return new Date(0);
-  }
-}
-
 export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
-  const [timeRange, setTimeRange] = useState<TimeRange>('30d');
-
-  // Filter archives by time range
-  const filteredArchives = useMemo(() => {
-    const startDate = getDateRange(timeRange);
-    return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate);
-  }, [archives, timeRange]);
+  const { t } = useTranslation();
+  const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');
+  const [colorMetric, setColorMetric] = useState<'weight' | 'prints'>('prints');
 
   // Calculate daily usage data
   const dailyData = useMemo(() => {
     const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
 
-    filteredArchives.forEach(archive => {
+    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')}`;
@@ -73,25 +51,24 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         ...d,
         dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
       }));
-  }, [filteredArchives]);
+  }, [archives]);
 
-  // Calculate weekly aggregated data for longer time ranges
+  // Calculate weekly aggregated data when there are many daily points
   const weeklyData = useMemo(() => {
-    if (timeRange === '7d' || timeRange === '30d') return dailyData;
+    if (dailyData.length <= 60) return dailyData;
 
     const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
 
-    filteredArchives.forEach(archive => {
-      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
-      // Get week start (Sunday)
+    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 += archive.filament_used_grams || 0;
-      existing.cost += archive.cost || 0;
-      existing.prints += archive.quantity || 1;
+      existing.filament += day.filament;
+      existing.cost += day.cost;
+      existing.prints += day.prints;
       dataMap.set(key, existing);
     });
 
@@ -102,13 +79,13 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
         ...d,
       }));
-  }, [filteredArchives, dailyData, timeRange]);
+  }, [dailyData]);
 
   // Usage by filament type
   const filamentTypeData = useMemo(() => {
     const dataMap = new Map<string, number>();
 
-    filteredArchives.forEach(archive => {
+    archives.forEach(archive => {
       const type = archive.filament_type || 'Unknown';
       // Handle multiple types (e.g., "PLA, PETG")
       const types = type.split(', ');
@@ -121,80 +98,119 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
     return Array.from(dataMap.entries())
       .map(([name, value]) => ({ name, value: Math.round(value) }))
       .sort((a, b) => b.value - a.value);
-  }, [filteredArchives]);
+  }, [archives]);
 
-  // Monthly comparison data
-  const monthlyComparison = useMemo(() => {
-    const now = new Date();
-    const months: { month: string; filament: number; cost: number; prints: number }[] = [];
+  // Usage by filament type (print count)
+  const filamentTypePrintData = useMemo(() => {
+    const dataMap = new Map<string, number>();
+    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]);
 
-    for (let i = 5; i >= 0; i--) {
-      const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
-      const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
-      const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
+  // Usage by filament type (print time in hours)
+  const filamentTypeTimeData = useMemo(() => {
+    const dataMap = new Map<string, number>();
+    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]);
 
-      const monthArchives = archives.filter(a => {
-        const d = parseUTCDate(a.completed_at || a.created_at) || new Date(0);
-        return d >= monthDate && d <= monthEnd;
+  // Success rate by filament type
+  const filamentSuccessData = useMemo(() => {
+    const map = new Map<string, { completed: number; failed: number }>();
+    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<string, { count: number; weight: number }>();
 
-      months.push({
-        month: monthStr,
-        filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
-        cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
-        prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
+    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 months;
-  }, [archives]);
+    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 chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
-  const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
-  const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
-  const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
+  const activeFilamentTypeData =
+    filamentTypeMetric === 'weight' ? filamentTypeData :
+    filamentTypeMetric === 'prints' ? filamentTypePrintData :
+    filamentTypeTimeData;
 
-  return (
-    <div className="space-y-6">
-      {/* Time Range Selector */}
-      <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-2">
-        <h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
-        <div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
-          {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
-            <button
-              key={range}
-              onClick={() => setTimeRange(range)}
-              className={`px-3 py-1 text-sm rounded-md transition-colors ${
-                timeRange === range
-                  ? 'bg-bambu-green text-white'
-                  : 'text-bambu-gray hover:text-white'
-              }`}
-            >
-              {range === 'all' ? 'All' : range.replace('d', 'D')}
-            </button>
-          ))}
-        </div>
-      </div>
+  const chartData = 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 (
+    <div className="space-y-4">
       {/* Summary Cards */}
-      <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
+      <div className="grid grid-cols-3 gap-2 max-[640px]:grid-cols-1">
         <div className="bg-bambu-dark rounded-lg p-4">
-          <div className="flex items-center justify-between gap-3">
-            <p className="text-sm text-bambu-gray leading-none">Period Filament</p>
-            <p className="text-2xl font-bold text-white leading-none">{(totalFilament / 1000).toFixed(2)}kg</p>
+          <div className="flex items-center justify-between gap-2">
+            <p className="text-sm text-bambu-gray leading-none">{t('stats.periodFilament')}</p>
+            <p className="text-2xl font-bold text-white leading-none">{formatWeight(totalFilament)}</p>
           </div>
-          <p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
+          <p className="text-xs text-bambu-gray">{printerCount} {t('nav.printers').toLowerCase()}</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <div className="flex items-center justify-between gap-3">
-            <p className="text-sm text-bambu-gray leading-none">Period Cost</p>
+          <div className="flex items-center justify-between gap-2">
+            <p className="text-sm text-bambu-gray leading-none">{t('stats.periodCost')}</p>
             <p className="text-2xl font-bold text-white leading-none">{currency}{totalCost.toFixed(2)}</p>
           </div>
-          <p className="text-xs text-bambu-gray">{totalPrints} prints</p>
+          <p className="text-xs text-bambu-gray">{totalPrints} {t('common.prints')}</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <div className="flex items-center justify-between gap-3">
-            <p className="text-sm text-bambu-gray leading-none">Avg per Print</p>
+          <div className="flex items-center justify-between gap-2">
+            <p className="text-sm text-bambu-gray leading-none">{t('stats.avgPerPrint')}</p>
             <p className="text-2xl font-bold text-white leading-none">
               {totalPrints > 0
                 ? (totalFilament / totalPrints).toFixed(0)
@@ -210,7 +226,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       {/* Usage Over Time Chart */}
       {chartData.length > 0 ? (
         <div className="bg-bambu-dark rounded-lg p-4">
-          <h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
+          <h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.usageOverTime')}</h4>
           <ResponsiveContainer width="100%" height={250}>
             <AreaChart data={chartData}>
               <defs>
@@ -253,21 +269,24 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         </div>
       ) : (
         <div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
-          No data for selected time range
+          {t('stats.noPrintDataInRange')}
         </div>
       )}
 
       {/* Bottom Charts */}
-      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
         {/* Filament Type Distribution */}
         <div className="bg-bambu-dark rounded-lg p-4">
-          <h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
-          {filamentTypeData.length > 0 ? (
+          <div className="flex items-center justify-between mb-4">
+            <h4 className="text-sm font-medium text-bambu-gray">{t('stats.byMaterial')}</h4>
+            <MetricToggle value={filamentTypeMetric} onChange={setFilamentTypeMetric} />
+          </div>
+          {activeFilamentTypeData.length > 0 ? (
             <div className="flex items-center gap-4">
               <ResponsiveContainer width={160} height={160}>
                 <PieChart>
                   <Pie
-                    data={filamentTypeData}
+                    data={activeFilamentTypeData}
                     cx="50%"
                     cy="50%"
                     innerRadius={40}
@@ -275,7 +294,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                     paddingAngle={2}
                     dataKey="value"
                   >
-                    {filamentTypeData.map((_, index) => (
+                    {activeFilamentTypeData.map((_, index) => (
                       <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
                     ))}
                   </Pie>
@@ -285,13 +304,18 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                       border: '1px solid #3d3d3d',
                       borderRadius: '8px',
                     }}
-                    formatter={(value) => [`${value ?? 0}g`, 'Usage']}
+                    formatter={(value) => [
+                      filamentTypeMetric === 'weight' ? formatWeight(Number(value ?? 0)) :
+                      filamentTypeMetric === 'time' ? `${Number(value ?? 0)}h` :
+                      `${value ?? 0}`,
+                      filamentTypeMetric === 'weight' ? 'Usage' : filamentTypeMetric === 'time' ? 'Time' : 'Prints',
+                    ]}
                   />
                 </PieChart>
               </ResponsiveContainer>
               <div className="flex-1 space-y-2 overflow-hidden">
-                {filamentTypeData.map((entry, index) => {
-                  const total = filamentTypeData.reduce((sum, e) => sum + e.value, 0);
+                {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 (
                     <div key={entry.name} className="flex items-center gap-2 text-sm">
@@ -300,7 +324,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                         style={{ backgroundColor: COLORS[index % COLORS.length] }}
                       />
                       <span className="text-white truncate flex-1">{entry.name}</span>
-                      <span className="text-bambu-gray flex-shrink-0">{percent}%</span>
+                      <span className="text-bambu-gray flex-shrink-0">
+                        {filamentTypeMetric === 'weight' ? formatWeight(entry.value) :
+                         filamentTypeMetric === 'time' ? `${entry.value}h` :
+                         entry.value} · {percent}%
+                      </span>
                     </div>
                   );
                 })}
@@ -308,34 +336,114 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
             </div>
           ) : (
             <div className="h-[160px] flex items-center justify-center text-bambu-gray">
-              No filament data
+              {t('stats.noFilamentData')}
             </div>
           )}
         </div>
 
-        {/* Monthly Comparison */}
+        {/* Success by Material */}
         <div className="bg-bambu-dark rounded-lg p-4">
-          <h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
-          <ResponsiveContainer width="100%" height={200}>
-            <BarChart data={monthlyComparison}>
-              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
-              <XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
-              <YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
-              <Tooltip
-                contentStyle={{
-                  backgroundColor: '#2d2d2d',
-                  border: '1px solid #3d3d3d',
-                  borderRadius: '8px',
-                }}
-                formatter={(value, name) => [
-                  name === 'filament' ? `${value ?? 0}g` : name === 'cost' ? `${currency}${Number(value ?? 0).toFixed(2)}` : value ?? 0,
-                  name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
-                ]}
-              />
-              <Legend />
-              <Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
-            </BarChart>
-          </ResponsiveContainer>
+          <h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.filamentSuccess')}</h4>
+          {filamentSuccessData.length > 0 ? (
+            <div className="space-y-1.5">
+              {filamentSuccessData.map(d => (
+                <div key={d.name} className="flex items-center gap-2 text-sm">
+                  <span className="text-white truncate w-20 flex-shrink-0">{d.name}</span>
+                  <div className="flex-1 h-1.5 bg-bambu-dark-secondary rounded-full">
+                    <div
+                      className={`h-full rounded-full transition-all ${
+                        d.rate >= 90 ? 'bg-status-ok' : d.rate >= 70 ? 'bg-status-warning' : 'bg-status-error'
+                      }`}
+                      style={{ width: `${d.rate}%` }}
+                    />
+                  </div>
+                  <span className={`font-medium flex-shrink-0 tabular-nums ${
+                    d.rate >= 90 ? 'text-status-ok' : d.rate >= 70 ? 'text-status-warning' : 'text-status-error'
+                  }`}>
+                    {d.rate}%
+                  </span>
+                  <span className="text-bambu-gray flex-shrink-0 text-xs">({d.total})</span>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <div className="h-[160px] flex items-center justify-center text-bambu-gray">
+              {t('stats.noArchiveData')}
+            </div>
+          )}
+        </div>
+
+        {/* Color Distribution */}
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <div className="flex items-center justify-between mb-4">
+            <h4 className="text-sm font-medium text-bambu-gray">{t('stats.colorDistribution')}</h4>
+            <div className="flex gap-0.5 bg-bambu-dark-secondary rounded-lg p-0.5">
+              <button onClick={() => setColorMetric('prints')}
+                className={`px-2 py-0.5 text-xs rounded-md transition-colors ${colorMetric === 'prints' ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'}`}>
+                {t('stats.filamentByPrints')}
+              </button>
+              <button onClick={() => setColorMetric('weight')}
+                className={`px-2 py-0.5 text-xs rounded-md transition-colors ${colorMetric === 'weight' ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'}`}>
+                {t('stats.filamentByWeight')}
+              </button>
+            </div>
+          </div>
+          {colorData.length > 0 ? (() => {
+            const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0);
+            return (
+              <div className="flex items-center gap-4">
+                <ResponsiveContainer width={120} height={120}>
+                  <PieChart>
+                    <Pie
+                      data={colorData}
+                      cx="50%"
+                      cy="50%"
+                      innerRadius={30}
+                      outerRadius={50}
+                      paddingAngle={2}
+                      dataKey="value"
+                    >
+                      {colorData.map((entry, index) => (
+                        <Cell key={`color-${index}`} fill={entry.hex} stroke="#1a1a1a" strokeWidth={1} />
+                      ))}
+                    </Pie>
+                    <Tooltip
+                      contentStyle={{
+                        backgroundColor: '#2d2d2d',
+                        border: '1px solid #3d3d3d',
+                        borderRadius: '8px',
+                      }}
+                      formatter={(value) => [
+                        colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`,
+                        colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'),
+                      ]}
+                    />
+                  </PieChart>
+                </ResponsiveContainer>
+                <div className="flex-1 space-y-1 overflow-hidden max-h-[120px] overflow-y-auto">
+                  {colorData.slice(0, 8).map((entry) => {
+                    const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0;
+                    return (
+                      <div key={entry.hex} className="flex items-center gap-2 text-sm">
+                        <div className="w-3 h-3 rounded-full flex-shrink-0 border border-white/20"
+                          style={{ backgroundColor: entry.hex }} />
+                        <span className="text-bambu-gray flex-shrink-0 text-xs">
+                          {colorMetric === 'weight' ? formatWeight(entry.value) : entry.value} · {percent}%
+                        </span>
+                      </div>
+                    );
+                  })}
+                  {colorData.length > 8 && (
+                    <p className="text-xs text-bambu-gray">+{colorData.length - 8} more</p>
+                  )}
+                </div>
+              </div>
+            );
+          })() : (
+            <div className="h-[120px] flex items-center justify-center text-bambu-gray">
+              {t('stats.noColorData')}
+            </div>
+          )}
         </div>
       </div>
     </div>

+ 36 - 0
frontend/src/components/MetricToggle.tsx

@@ -0,0 +1,36 @@
+import { useTranslation } from 'react-i18next';
+
+export type Metric = 'weight' | 'prints' | 'time';
+
+const METRICS: Metric[] = ['weight', 'prints', 'time'];
+
+interface MetricToggleProps {
+  value: Metric;
+  onChange: (metric: Metric) => void;
+}
+
+export function MetricToggle({ value, onChange }: MetricToggleProps) {
+  const { t } = useTranslation();
+
+  const labels: Record<Metric, string> = {
+    weight: t('stats.filamentByWeight'),
+    prints: t('stats.filamentByPrints'),
+    time: t('stats.filamentByTime'),
+  };
+
+  return (
+    <div className="flex gap-0.5 bg-bambu-dark rounded-lg p-0.5">
+      {METRICS.map(m => (
+        <button
+          key={m}
+          onClick={() => onChange(m)}
+          className={`px-2 py-0.5 text-xs rounded-md transition-colors ${
+            value === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
+          }`}
+        >
+          {labels[m]}
+        </button>
+      ))}
+    </div>
+  );
+}

+ 42 - 0
frontend/src/i18n/locales/de.ts

@@ -983,6 +983,48 @@ export default {
     // Permissions
     noPermissionResetLayout: 'Sie haben keine Berechtigung, das Layout zurückzusetzen',
     noPermissionRecalculate: 'Sie haben keine Berechtigung, Kosten neu zu berechnen',
+    noPrintDataInRange: 'Keine Druckdaten im ausgewählten Zeitraum',
+    periodFilament: 'Filamentverbrauch',
+    periodCost: 'Kosten',
+    avgPerPrint: 'Durchschnitt pro Druck',
+    usageOverTime: 'Verbrauch im Zeitverlauf',
+    filamentByWeight: 'Gewicht',
+    printDuration: 'Druckdauer',
+    printerUtilization: 'Druckerauslastung',
+    filamentSuccess: 'Erfolg nach Material',
+    printHabits: 'Druckgewohnheiten',
+    printTimeOfDay: 'Druck-Tageszeit',
+    colorDistribution: 'Farbverteilung',
+    noColorData: 'Keine Farbdaten verfügbar',
+    records: 'Rekorde',
+    longestPrint: 'Längster Druck',
+    heaviestPrint: 'Schwerster Druck',
+    mostExpensivePrint: 'Teuerster Druck',
+    busiestDay: 'Aktivster Tag',
+    successStreak: 'Erfolgsserie',
+    streakPrint: 'aufeinanderfolgender Druck',
+    streakPrints: '{{count}} aufeinanderfolgende Drucke',
+    printerStats: 'Druckerstatistiken',
+    hours: 'Stunden',
+    avgPrints: 'Ø Drucke',
+    noArchiveData: 'Keine Druckdaten verfügbar',
+    filamentByTime: 'Zeitverlauf',
+    avgWeight: 'Ø Gewicht',
+    avgTime: 'Ø Zeit',
+    filamentByPrints: 'Drucke',
+    timeframe: {
+      'today': 'Heute',
+      'this-week': 'Diese Woche',
+      'this-month': 'Dieser Monat',
+      'last-7': 'Letzte 7 Tage',
+      'last-30': 'Letzte 30 Tage',
+      'last-90': 'Letzte 90 Tage',
+      'this-year': 'Dieses Jahr',
+      'all-time': 'Gesamt',
+      'custom': 'Benutzerdefiniert',
+      from: 'Von',
+      to: 'Bis',
+    },
   },
 
   // Maintenance page

+ 42 - 0
frontend/src/i18n/locales/en.ts

@@ -983,6 +983,48 @@ export default {
     // Permissions
     noPermissionResetLayout: 'You do not have permission to reset layout',
     noPermissionRecalculate: 'You do not have permission to recalculate costs',
+    noPrintDataInRange: 'No print data in selected range',
+    periodFilament: 'Period Filament',
+    periodCost: 'Period Cost',
+    avgPerPrint: 'Avg per Print',
+    usageOverTime: 'Usage Over Time',
+    filamentByWeight: 'Weight',
+    printDuration: 'Print Duration',
+    printerUtilization: 'Printer Utilization',
+    filamentSuccess: 'Success by Material',
+    printHabits: 'Print Habits',
+    printTimeOfDay: 'Print Time of Day',
+    colorDistribution: 'Color Distribution',
+    noColorData: 'No color data available',
+    records: 'Records',
+    longestPrint: 'Longest Print',
+    heaviestPrint: 'Heaviest Print',
+    mostExpensivePrint: 'Most Expensive',
+    busiestDay: 'Busiest Day',
+    successStreak: 'Success Streak',
+    streakPrint: 'consecutive print',
+    streakPrints: '{{count}} consecutive prints',
+    printerStats: 'Printer Stats',
+    hours: 'hours',
+    avgPrints: 'Avg. prints',
+    noArchiveData: 'No print data available',
+    filamentByTime: 'Time',
+    avgWeight: 'Avg. weight',
+    avgTime: 'Avg. time',
+    filamentByPrints: 'Prints',
+    timeframe: {
+      'today': 'Today',
+      'this-week': 'This Week',
+      'this-month': 'This Month',
+      'last-7': 'Last 7 Days',
+      'last-30': 'Last 30 Days',
+      'last-90': 'Last 90 Days',
+      'this-year': 'This Year',
+      'all-time': 'All Time',
+      'custom': 'Custom Range',
+      from: 'From',
+      to: 'To',
+    },
   },
 
   // Maintenance page

+ 42 - 0
frontend/src/i18n/locales/fr.ts

@@ -975,6 +975,48 @@ export default {
     // Permissions
     noPermissionResetLayout: 'Pas d\'autorisation de réinitialisation',
     noPermissionRecalculate: 'Pas d\'autorisation de recalcul',
+    noPrintDataInRange: 'Aucune donnée dans la période sélectionnée',
+    periodFilament: 'Filament utilisé',
+    periodCost: 'Coût',
+    avgPerPrint: 'Moy. par impression',
+    usageOverTime: 'Utilisation dans le temps',
+    filamentByWeight: 'Poids',
+    printDuration: 'Durée d\'impression',
+    printerUtilization: 'Utilisation imprimante',
+    filamentSuccess: 'Succès par matériau',
+    printHabits: 'Habitudes d\'impression',
+    printTimeOfDay: 'Heure d\'impression',
+    colorDistribution: 'Distribution des couleurs',
+    noColorData: 'Aucune donnée de couleur disponible',
+    records: 'Records',
+    longestPrint: 'Plus longue impression',
+    heaviestPrint: 'Plus lourde impression',
+    mostExpensivePrint: 'Plus chère',
+    busiestDay: 'Jour le plus actif',
+    successStreak: 'Série de succès',
+    streakPrint: 'impression consécutive',
+    streakPrints: '{{count}} impressions consécutives',
+    printerStats: 'Stats imprimante',
+    hours: 'heures',
+    avgPrints: 'Moy. impressions',
+    noArchiveData: 'Aucune donnée d\'impression disponible',
+    filamentByTime: 'Temps',
+    avgWeight: 'Moy. poids',
+    avgTime: 'Moy. temps',
+    filamentByPrints: 'Impressions',
+    timeframe: {
+      'today': 'Aujourd\'hui',
+      'this-week': 'Cette semaine',
+      'this-month': 'Ce mois',
+      'last-7': '7 derniers jours',
+      'last-30': '30 derniers jours',
+      'last-90': '90 derniers jours',
+      'this-year': 'Cette année',
+      'all-time': 'Tout',
+      'custom': 'Personnalisé',
+      from: 'Du',
+      to: 'Au',
+    },
   },
 
   // Maintenance page

+ 42 - 0
frontend/src/i18n/locales/it.ts

@@ -962,6 +962,48 @@ export default {
     // Permissions
     noPermissionResetLayout: 'Non hai il permesso di reimpostare il layout',
     noPermissionRecalculate: 'Non hai il permesso di ricalcolare i costi',
+    noPrintDataInRange: 'Nessun dato nel periodo selezionato',
+    periodFilament: 'Filamento utilizzato',
+    periodCost: 'Costo',
+    avgPerPrint: 'Media per stampa',
+    usageOverTime: 'Utilizzo nel tempo',
+    filamentByWeight: 'Peso',
+    printDuration: 'Durata stampa',
+    printerUtilization: 'Utilizzo stampante',
+    filamentSuccess: 'Successo per materiale',
+    printHabits: 'Abitudini di stampa',
+    printTimeOfDay: 'Ora di stampa',
+    colorDistribution: 'Distribuzione colori',
+    noColorData: 'Nessun dato colore disponibile',
+    records: 'Record',
+    longestPrint: 'Stampa più lunga',
+    heaviestPrint: 'Stampa più pesante',
+    mostExpensivePrint: 'Più costosa',
+    busiestDay: 'Giorno più attivo',
+    successStreak: 'Serie di successi',
+    streakPrint: 'stampa consecutiva',
+    streakPrints: '{{count}} stampe consecutive',
+    printerStats: 'Statistiche stampante',
+    hours: 'ore',
+    avgPrints: 'Media stampe',
+    noArchiveData: 'Nessun dato di stampa disponibile',
+    filamentByTime: 'Tempo',
+    avgWeight: 'Media peso',
+    avgTime: 'Media tempo',
+    filamentByPrints: 'Stampe',
+    timeframe: {
+      'today': 'Oggi',
+      'this-week': 'Questa settimana',
+      'this-month': 'Questo mese',
+      'last-7': 'Ultimi 7 giorni',
+      'last-30': 'Ultimi 30 giorni',
+      'last-90': 'Ultimi 90 giorni',
+      'this-year': 'Quest\'anno',
+      'all-time': 'Tutto',
+      'custom': 'Personalizzato',
+      from: 'Da',
+      to: 'A',
+    },
   },
 
   // Maintenance page

+ 42 - 0
frontend/src/i18n/locales/ja.ts

@@ -1043,6 +1043,48 @@ export default {
     recalculateCostsHint: '現在のフィラメント価格ですべてのアーカイブコストを再計算',
     recalculatedCosts: '{{count}}件のアーカイブのコストを再計算しました',
     loadingStats: '統計を読み込み中...',
+    noPrintDataInRange: '選択した期間にデータがありません',
+    periodFilament: 'フィラメント使用量',
+    periodCost: 'コスト',
+    avgPerPrint: '1印刷あたりの平均',
+    usageOverTime: '時間推移',
+    filamentByWeight: '重量',
+    printDuration: '印刷時間分布',
+    printerUtilization: 'プリンター稼働率',
+    filamentSuccess: '素材別成功率',
+    printHabits: '印刷習慣',
+    printTimeOfDay: '時間帯別プリント',
+    colorDistribution: 'カラー分布',
+    noColorData: 'カラーデータがありません',
+    records: '記録',
+    longestPrint: '最長プリント',
+    heaviestPrint: '最重プリント',
+    mostExpensivePrint: '最高額',
+    busiestDay: '最多プリント日',
+    successStreak: '連続成功',
+    streakPrint: '連続プリント',
+    streakPrints: '{{count}}連続プリント',
+    printerStats: 'プリンター統計',
+    hours: '時間',
+    avgPrints: '平均印刷数',
+    noArchiveData: '印刷データがありません',
+    filamentByTime: '推移',
+    avgWeight: '平均重量',
+    avgTime: '平均時間',
+    filamentByPrints: '印刷数',
+    timeframe: {
+      'today': '今日',
+      'this-week': '今週',
+      'this-month': '今月',
+      'last-7': '過去7日間',
+      'last-30': '過去30日間',
+      'last-90': '過去90日間',
+      'this-year': '今年',
+      'all-time': '全期間',
+      'custom': 'カスタム',
+      from: '開始',
+      to: '終了',
+    },
   },
   maintenance: {
     title: 'メンテナンス',

+ 42 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -983,6 +983,48 @@ export default {
     // Permissions
     noPermissionResetLayout: 'Você não tem permissão para redefinir o layout',
     noPermissionRecalculate: 'Você não tem permissão para recalcular custos',
+    noPrintDataInRange: 'Sem dados no período selecionado',
+    periodFilament: 'Filamento usado',
+    periodCost: 'Custo',
+    avgPerPrint: 'Média por impressão',
+    usageOverTime: 'Uso ao longo do tempo',
+    filamentByWeight: 'Peso',
+    printDuration: 'Duração da impressão',
+    printerUtilization: 'Utilização da impressora',
+    filamentSuccess: 'Sucesso por material',
+    printHabits: 'Hábitos de impressão',
+    printTimeOfDay: 'Horário de impressão',
+    colorDistribution: 'Distribuição de cores',
+    noColorData: 'Nenhum dado de cor disponível',
+    records: 'Recordes',
+    longestPrint: 'Impressão mais longa',
+    heaviestPrint: 'Impressão mais pesada',
+    mostExpensivePrint: 'Mais cara',
+    busiestDay: 'Dia mais movimentado',
+    successStreak: 'Sequência de sucesso',
+    streakPrint: 'impressão consecutiva',
+    streakPrints: '{{count}} impressões consecutivas',
+    printerStats: 'Estatísticas da impressora',
+    hours: 'horas',
+    avgPrints: 'Méd. impressões',
+    noArchiveData: 'Nenhum dado de impressão disponível',
+    filamentByTime: 'Tempo',
+    avgWeight: 'Méd. peso',
+    avgTime: 'Méd. tempo',
+    filamentByPrints: 'Impressões',
+    timeframe: {
+      'today': 'Hoje',
+      'this-week': 'Esta semana',
+      'this-month': 'Este mês',
+      'last-7': 'Últimos 7 dias',
+      'last-30': 'Últimos 30 dias',
+      'last-90': 'Últimos 90 dias',
+      'this-year': 'Este ano',
+      'all-time': 'Todo o período',
+      'custom': 'Personalizado',
+      from: 'De',
+      to: 'Até',
+    },
   },
 
   // Maintenance page

+ 565 - 140
frontend/src/pages/StatsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery } from '@tanstack/react-query';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Package,
@@ -7,7 +7,6 @@ import {
   CheckCircle,
   XCircle,
   DollarSign,
-  Printer,
   Target,
   Zap,
   AlertTriangle,
@@ -18,21 +17,107 @@ import {
   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 } from '../api/client';
+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,
-  t,
 }: {
   stats: {
     total_prints: number;
@@ -45,8 +130,8 @@ function QuickStatsWidget({
     total_energy_cost: number;
   } | undefined;
   currency: string;
-  t: (key: string) => string;
 }) {
+  const { t } = useTranslation();
   return (
     <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
       <div className="flex items-start gap-3">
@@ -73,7 +158,7 @@ function QuickStatsWidget({
         </div>
         <div>
           <p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
-          <p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
+          <p className="text-xl font-bold text-white">{formatWeight(stats?.total_filament_grams || 0)}</p>
         </div>
       </div>
       <div className="flex items-start gap-3">
@@ -111,7 +196,6 @@ function SuccessRateWidget({
   stats,
   printerMap,
   size = 1,
-  t,
 }: {
   stats: {
     total_prints: number;
@@ -121,8 +205,8 @@ function SuccessRateWidget({
   } | undefined;
   printerMap: Map<string, string>;
   size?: 1 | 2 | 4;
-  t: (key: string) => string;
 }) {
+  const { t } = useTranslation();
   const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);
   const successRate = completedAndFailed
     ? Math.round((stats!.successful_prints / completedAndFailed) * 100)
@@ -198,7 +282,6 @@ function TimeAccuracyWidget({
   stats,
   printerMap,
   size = 1,
-  t,
 }: {
   stats: {
     average_time_accuracy: number | null;
@@ -206,8 +289,8 @@ function TimeAccuracyWidget({
   } | undefined;
   printerMap: Map<string, string>;
   size?: 1 | 2 | 4;
-  t: (key: string) => string;
 }) {
+  const { t } = useTranslation();
   const accuracy = stats?.average_time_accuracy;
 
   if (accuracy === null || accuracy === undefined) {
@@ -300,85 +383,6 @@ function TimeAccuracyWidget({
   );
 }
 
-function FilamentTypesWidget({
-  stats,
-  size = 1,
-  t,
-}: {
-  stats: {
-    total_prints: number;
-    prints_by_filament_type: Record<string, number>;
-  } | undefined;
-  size?: 1 | 2 | 4;
-  t: (key: string, options?: Record<string, unknown>) => string;
-}) {
-  if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
-    return <p className="text-bambu-gray text-center py-4">{t('stats.noFilamentData')}</p>;
-  }
-
-  // Sort by print count descending
-  const sortedEntries = Object.entries(stats.prints_by_filament_type).sort(
-    ([, a], [, b]) => b - a
-  );
-
-  // Limit entries based on size
-  const maxEntries = size === 1 ? 5 : size === 2 ? 8 : 999;
-  const displayEntries = sortedEntries.slice(0, maxEntries);
-  const hasMore = sortedEntries.length > maxEntries;
-
-  // Use grid layout when expanded
-  if (size === 4 && displayEntries.length > 4) {
-    return (
-      <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
-        {displayEntries.map(([type, count]) => {
-          const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
-          return (
-            <div key={type}>
-              <div className="flex justify-between text-sm mb-1">
-                <span className="text-white truncate max-w-[120px]">{type}</span>
-                <span className="text-bambu-gray">{count}</span>
-              </div>
-              <div className="h-2 bg-bambu-dark rounded-full">
-                <div
-                  className="h-full bg-bambu-green rounded-full transition-all"
-                  style={{ width: `${percentage}%` }}
-                />
-              </div>
-            </div>
-          );
-        })}
-      </div>
-    );
-  }
-
-  return (
-    <div className="space-y-3">
-      {displayEntries.map(([type, count]) => {
-        const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
-        return (
-          <div key={type}>
-            <div className="flex justify-between text-sm mb-1">
-              <span className="text-white">{type}</span>
-              <span className="text-bambu-gray">{count} {t('common.prints')}</span>
-            </div>
-            <div className="h-2 bg-bambu-dark rounded-full">
-              <div
-                className="h-full bg-bambu-green rounded-full transition-all"
-                style={{ width: `${percentage}%` }}
-              />
-            </div>
-          </div>
-        );
-      })}
-      {hasMore && (
-        <p className="text-xs text-bambu-gray text-center pt-1">
-          {t('common.more', { count: sortedEntries.length - maxEntries })}
-        </p>
-      )}
-    </div>
-  );
-}
-
 function PrintActivityWidget({
   printDates,
   size = 2,
@@ -391,34 +395,205 @@ function PrintActivityWidget({
   return <PrintCalendar printDates={printDates} months={months} />;
 }
 
-function PrintsByPrinterWidget({
+function PrinterStatsWidget({
   stats,
+  archives,
   printerMap,
-  t,
 }: {
   stats: { prints_by_printer: Record<string, number> } | undefined;
+  archives: Archive[];
   printerMap: Map<string, string>;
-  t: (key: string) => string;
 }) {
-  if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
-    return <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>;
-  }
+  const { t } = useTranslation();
+  const [printerMetric, setPrinterMetric] = useState<Metric>('weight');
+  const [habitsMetric, setHabitsMetric] = useState<Metric>('weight');
+
+  // Per-printer data
+  const printerData = useMemo(() => {
+    const map = new Map<string, { prints: number; weight: number; time: number }>();
+    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<string>();
+    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 (
-    <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
-      {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
-        <div key={printerId} className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
-          <div className="p-2 bg-bambu-dark-tertiary rounded-lg">
-            <Printer className="w-4 h-4 text-bambu-green" />
-          </div>
-          <div>
-            <p className="text-white font-medium text-sm">
-              {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
-            </p>
-            <p className="text-xs text-bambu-gray">{count} {t('common.prints')}</p>
+    <div className="space-y-4">
+      {/* By Printer */}
+      <div className="bg-bambu-dark rounded-lg p-4">
+        <div className="flex items-center justify-between mb-3">
+          <h4 className="text-sm font-medium text-bambu-gray">{t('stats.printsByPrinter')}</h4>
+          <MetricToggle value={printerMetric} onChange={setPrinterMetric} />
+        </div>
+        {printerData.length > 0 ? (
+          <ResponsiveContainer width="100%" height={Math.max(140, printerData.length * 40)}>
+            <BarChart data={printerData} layout="vertical" margin={{ left: 10 }}>
+              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+              <XAxis type="number" stroke="#9ca3af" tick={{ fontSize: 11 }} unit={pUnit} />
+              <YAxis type="category" dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} width={100} />
+              <Tooltip
+                contentStyle={RECHARTS_TOOLTIP_STYLE}
+                formatter={(v: number | undefined) => [
+                  printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${pUnit}`,
+                  pLabel,
+                ]}
+              />
+              <Bar dataKey="value" fill={pColor} radius={[0, 4, 4, 0]} />
+            </BarChart>
+          </ResponsiveContainer>
+        ) : (
+          <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>
+        )}
+      </div>
+
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
+        {/* Print Duration */}
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <h4 className="text-sm font-medium text-bambu-gray mb-3">{t('stats.printDuration')}</h4>
+          {archives.length > 0 ? (
+            <ResponsiveContainer width="100%" height={160}>
+              <BarChart data={durationData}>
+                <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+                <XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} />
+                <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} allowDecimals={false} />
+                <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />
+                <Bar dataKey="count" name={t('common.prints')} fill="#00ae42" radius={[4, 4, 0, 0]} />
+              </BarChart>
+            </ResponsiveContainer>
+          ) : (
+            <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
+          )}
+        </div>
+
+        {/* Print Habits */}
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h4 className="text-sm font-medium text-bambu-gray">{t('stats.printHabits')}</h4>
+            <MetricToggle value={habitsMetric} onChange={setHabitsMetric} />
           </div>
+          {archives.length > 0 ? (
+            <ResponsiveContainer width="100%" height={160}>
+              <BarChart data={habitsData}>
+                <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+                <XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} />
+                <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} unit={hUnit} />
+                <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} formatter={(v: number | undefined) => [`${v ?? 0}${hUnit}`, hLabel]} />
+                <Bar dataKey="avg" fill={hColor} radius={[4, 4, 0, 0]} />
+              </BarChart>
+            </ResponsiveContainer>
+          ) : (
+            <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
+          )}
         </div>
-      ))}
+
+        {/* Print Time of Day */}
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <h4 className="text-sm font-medium text-bambu-gray mb-3">{t('stats.printTimeOfDay')}</h4>
+          {archives.length > 0 ? (
+            <ResponsiveContainer width="100%" height={160}>
+              <BarChart data={hourlyData}>
+                <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+                <XAxis dataKey="label" stroke="#9ca3af" tick={{ fontSize: 10 }} interval={5} />
+                <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} allowDecimals={false} />
+                <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />
+                <Bar dataKey="total" name={t('stats.totalPrints')} fill="#00ae42" radius={[2, 2, 0, 0]} />
+                <Bar dataKey="failures" name={t('stats.failed')} fill="#ef4444" radius={[2, 2, 0, 0]} />
+              </BarChart>
+            </ResponsiveContainer>
+          ) : (
+            <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
+          )}
+        </div>
+      </div>
     </div>
   );
 }
@@ -426,22 +601,29 @@ function PrintsByPrinterWidget({
 function FilamentTrendsWidget({
   archives,
   currency,
-  t,
 }: {
   archives: Parameters<typeof FilamentTrends>[0]['archives'];
   currency: string;
-  t: (key: string) => string;
 }) {
+  const { t } = useTranslation();
   if (!archives || archives.length === 0) {
     return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
   }
   return <FilamentTrends archives={archives} currency={currency} />;
 }
 
-function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record<string, unknown>) => string }) {
+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'],
-    queryFn: () => api.getFailureAnalysis({ days: 30 }),
+    queryKey: ['failureAnalysis', dateFrom, dateTo],
+    queryFn: () => api.getFailureAnalysis({
+      ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),
+    }),
   });
 
   if (isLoading) {
@@ -453,7 +635,7 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str
   }
 
   if (!analysis || analysis.total_prints === 0) {
-    return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintDataLast30Days')}</p>;
+    return <p className="text-bambu-gray text-center py-4">{hasDateRange ? t('stats.noPrintDataInRange') : t('stats.noPrintDataLast30Days')}</p>;
   }
 
   // Show more reasons when expanded
@@ -519,6 +701,150 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str
   );
 }
 
+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<string, number>();
+    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 <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>;
+  }
+
+  return (
+    <div className="space-y-3">
+      {records.map((record, i) => (
+        <div key={i} className="flex items-center gap-3">
+          <div className={`p-1.5 rounded-lg bg-bambu-dark ${record.iconColor}`}>
+            <record.icon className="w-4 h-4" />
+          </div>
+          <div className="flex-1 min-w-0">
+            <p className="text-xs text-bambu-gray">{record.label}</p>
+            <div className="flex items-baseline gap-2">
+              <span className="text-sm font-bold text-white">{record.value}</span>
+              {record.detail && (
+                <span className="text-xs text-bambu-gray truncate">{record.detail}</span>
+              )}
+            </div>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
 export function StatsPage() {
   const { t } = useTranslation();
   const { showToast } = useToast();
@@ -528,12 +854,25 @@ export function StatsPage() {
   const [dashboardKey, setDashboardKey] = useState(0);
   const [hiddenCount, setHiddenCount] = useState(0);
   const [isRecalculating, setIsRecalculating] = useState(false);
+  const [timeframe, setTimeframe] = useState<TimeframeState>({
+    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');
+        const saved = localStorage.getItem('bambusy-dashboard-layout-v2');
         if (saved) {
           const layout = JSON.parse(saved);
           setHiddenCount(layout.hidden?.length || 0);
@@ -546,7 +885,7 @@ export function StatsPage() {
     // 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, 500);
+    const interval = setInterval(updateHiddenCount, 2000);
     return () => {
       window.removeEventListener('storage', updateHiddenCount);
       clearInterval(interval);
@@ -554,8 +893,11 @@ export function StatsPage() {
   }, [dashboardKey]);
 
   const { data: stats, isLoading, refetch: refetchStats } = useQuery({
-    queryKey: ['archiveStats'],
-    queryFn: api.getArchiveStats,
+    queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
+    queryFn: () => api.getArchiveStats({
+      dateFrom: effectiveDateRange.dateFrom,
+      dateTo: effectiveDateRange.dateTo,
+    }),
   });
 
   const { data: printers } = useQuery({
@@ -563,9 +905,9 @@ export function StatsPage() {
     queryFn: api.getPrinters,
   });
 
-  const { data: archives } = useQuery({
-    queryKey: ['archives'],
-    queryFn: () => api.getArchives(undefined, undefined, 1000, 0),
+  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({
@@ -596,7 +938,7 @@ export function StatsPage() {
     setIsRecalculating(true);
     try {
       const result = await api.recalculateCosts();
-      await refetchStats();
+      await Promise.all([refetchStats(), refetchArchives()]);
       showToast(t('stats.recalculatedCosts', { count: result.updated }));
     } catch {
       showToast(t('stats.recalculateFailed'), 'error');
@@ -607,7 +949,7 @@ export function StatsPage() {
 
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
-  const printDates = archives?.map((a) => a.created_at) || [];
+  const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);
 
   if (isLoading) {
     return (
@@ -624,31 +966,25 @@ export function StatsPage() {
     {
       id: 'quick-stats',
       title: t('stats.quickStats'),
-      component: <QuickStatsWidget stats={stats} currency={currency} t={t} />,
+      component: <QuickStatsWidget stats={stats} currency={currency} />,
       defaultSize: 2,
     },
     {
       id: 'success-rate',
       title: t('stats.successRate'),
-      component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
+      component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'time-accuracy',
       title: t('stats.timeAccuracy'),
-      component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
-      defaultSize: 1,
-    },
-    {
-      id: 'filament-types',
-      title: t('stats.filamentTypes'),
-      component: (size) => <FilamentTypesWidget stats={stats} size={size} t={t} />,
+      component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'failure-analysis',
       title: t('stats.failureAnalysis'),
-      component: (size) => <FailureAnalysisWidget size={size} t={t} />,
+      component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
       defaultSize: 1,
     },
     {
@@ -658,20 +994,25 @@ export function StatsPage() {
       defaultSize: 2,
     },
     {
-      id: 'prints-by-printer',
-      title: t('stats.printsByPrinter'),
-      component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} t={t} />,
-      defaultSize: 2,
+      id: 'records',
+      title: t('stats.records'),
+      component: <RecordsWidget archives={archives || []} currency={currency} />,
+      defaultSize: 1,
+    },
+    {
+      id: 'printer-stats',
+      title: t('stats.printerStats'),
+      component: <PrinterStatsWidget stats={stats} archives={archives || []} printerMap={printerMap} />,
+      defaultSize: 4,
     },
     {
       id: 'filament-trends',
       title: t('stats.filamentTrends'),
-      component: <FilamentTrendsWidget archives={archives || []} currency={currency} t={t} />,
+      component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
       defaultSize: 4,
     },
   ];
 
-
   return (
     <div className="p-4 md:p-8">
       <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
@@ -697,7 +1038,7 @@ export function StatsPage() {
           <Button
             variant="secondary"
             onClick={() => {
-              localStorage.removeItem('bambusy-dashboard-layout');
+              localStorage.removeItem('bambusy-dashboard-layout-v2');
               setDashboardKey(prev => prev + 1);
               showToast(t('stats.layoutReset'));
             }}
@@ -754,13 +1095,97 @@ export function StatsPage() {
               </div>
             )}
           </div>
+          {/* Timeframe Selector */}
+          <div className="relative">
+            <Button
+              variant="secondary"
+              onClick={() => setShowTimeframePicker(!showTimeframePicker)}
+            >
+              <Calendar className="w-4 h-4" />
+              {t(`stats.timeframe.${timeframe.preset}`)}
+              <ChevronDown className="w-3 h-3" />
+            </Button>
+
+            {showTimeframePicker && (
+              <>
+                <div
+                  className="fixed inset-0 z-10"
+                  onClick={() => setShowTimeframePicker(false)}
+                />
+                <div className="absolute right-0 top-full mt-1 w-64 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 p-2">
+                  {TIMEFRAME_PRESETS.map((preset) => (
+                    <button
+                      key={preset}
+                      className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
+                        timeframe.preset === preset
+                          ? 'bg-bambu-green text-white'
+                          : 'text-white hover:bg-bambu-dark-tertiary'
+                      }`}
+                      onClick={() => {
+                        setTimeframe({ preset, dateFrom: undefined, dateTo: undefined });
+                        setShowTimeframePicker(false);
+                      }}
+                    >
+                      {t(`stats.timeframe.${preset}`)}
+                    </button>
+                  ))}
+
+                  <div className="border-t border-bambu-dark-tertiary my-2" />
+
+                  <button
+                    className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
+                      timeframe.preset === 'custom'
+                        ? 'bg-bambu-green text-white'
+                        : 'text-white hover:bg-bambu-dark-tertiary'
+                    }`}
+                    onClick={() => setTimeframe(prev => ({ ...prev, preset: 'custom' }))}
+                  >
+                    {t('stats.timeframe.custom')}
+                  </button>
+
+                  {timeframe.preset === 'custom' && (
+                    <div className="mt-2 px-1 pb-1 space-y-2">
+                      <div>
+                        <label className="text-xs text-bambu-gray block mb-1">{t('stats.timeframe.from')}</label>
+                        <input
+                          type="date"
+                          value={timeframe.dateFrom || ''}
+                          max={timeframe.dateTo || new Date().toISOString().split('T')[0]}
+                          onChange={(e) => 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]"
+                        />
+                      </div>
+                      <div>
+                        <label className="text-xs text-bambu-gray block mb-1">{t('stats.timeframe.to')}</label>
+                        <input
+                          type="date"
+                          value={timeframe.dateTo || ''}
+                          min={timeframe.dateFrom}
+                          max={new Date().toISOString().split('T')[0]}
+                          onChange={(e) => 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]"
+                        />
+                      </div>
+                      <Button
+                        variant="primary"
+                        onClick={() => setShowTimeframePicker(false)}
+                        className="w-full"
+                      >
+                        {t('common.apply')}
+                      </Button>
+                    </div>
+                  )}
+                </div>
+              </>
+            )}
+          </div>
         </div>
       </div>
 
       <Dashboard
         key={dashboardKey}
         widgets={widgets}
-        storageKey="bambusy-dashboard-layout"
+        storageKey="bambusy-dashboard-layout-v2"
         stackBelow={640}
         hideControls
       />

+ 11 - 0
frontend/src/utils/weight.ts

@@ -0,0 +1,11 @@
+export function formatWeight(grams: number): string {
+  if (grams >= 1_000_000) {
+    const tonnes = grams / 1_000_000;
+    return `${tonnes % 1 === 0 ? tonnes.toFixed(0) : tonnes.toFixed(1)}t`;
+  }
+  if (grams >= 1000) {
+    const kg = grams / 1000;
+    return `${kg % 1 === 0 ? kg.toFixed(0) : kg.toFixed(1)}kg`;
+  }
+  return `${Math.round(grams)}g`;
+}