Explorar el Código

Merge pull request #561 from aneopsy/statistic-timeframe

[Feature]: Statistics page - Add timeframe filtering, new widgets & fix success rate calculation
MartinNYHC hace 2 meses
padre
commit
eaaa4a2d26

+ 115 - 13
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
 
@@ -21,7 +22,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
-from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
@@ -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,
     )
@@ -149,6 +154,78 @@ async def list_archives(
     return result
 
 
+@router.get("/slim", response_model=list[ArchiveSlim])
+async def list_archives_slim(
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
+    limit: int = Query(default=10000, le=50000),
+    offset: int = 0,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Lightweight archive listing for stats/dashboard widgets.
+
+    Returns only the fields needed for client-side aggregation,
+    skipping duplicate detection, file paths, and extra_data.
+    """
+    filters = []
+    if date_from:
+        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at >= dt_from)
+    if date_to:
+        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at <= dt_to)
+
+    query = (
+        select(
+            PrintArchive.printer_id,
+            PrintArchive.print_name,
+            PrintArchive.print_time_seconds,
+            PrintArchive.started_at,
+            PrintArchive.completed_at,
+            PrintArchive.filament_used_grams,
+            PrintArchive.filament_type,
+            PrintArchive.filament_color,
+            PrintArchive.status,
+            PrintArchive.cost,
+            PrintArchive.quantity,
+            PrintArchive.created_at,
+        )
+        .where(*filters)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    result = await db.execute(query)
+    rows = result.all()
+
+    return [
+        {
+            "printer_id": r.printer_id,
+            "print_name": r.print_name,
+            "print_time_seconds": r.print_time_seconds,
+            "actual_time_seconds": (
+                int((r.completed_at - r.started_at).total_seconds())
+                if r.started_at
+                and r.completed_at
+                and r.status == "completed"
+                and (r.completed_at - r.started_at).total_seconds() > 0
+                else None
+            ),
+            "filament_used_grams": r.filament_used_grams,
+            "filament_type": r.filament_type,
+            "filament_color": r.filament_color,
+            "status": r.status,
+            "started_at": r.started_at,
+            "completed_at": r.completed_at,
+            "cost": r.cost,
+            "quantity": r.quantity,
+            "created_at": r.created_at,
+        }
+        for r in rows
+    ]
+
+
 @router.get("/search", response_model=list[ArchiveResponse])
 async def search_archives(
     q: str = Query(..., min_length=2, description="Search query"),
@@ -277,7 +354,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 +376,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 +521,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 +571,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 +593,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 +603,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 +677,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(

+ 21 - 0
backend/app/schemas/archive.py

@@ -110,6 +110,27 @@ class ArchiveResponse(BaseModel):
         from_attributes = True
 
 
+class ArchiveSlim(BaseModel):
+    """Lightweight archive response for stats/dashboard widgets."""
+
+    printer_id: int | None
+    print_name: str | None
+    print_time_seconds: int | None
+    actual_time_seconds: int | None = None
+    filament_used_grams: float | None
+    filament_type: str | None
+    filament_color: str | None
+    status: str
+    started_at: datetime | None
+    completed_at: datetime | None
+    cost: float | None
+    quantity: int = 1
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
 class ArchiveStats(BaseModel):
     total_prints: int
     successful_prints: int

+ 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())

+ 34 - 14
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,28 +16,47 @@ 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]
+        # Build base query — separate date vs non-date filters for trend reuse
+        base_filter = []
+        non_date_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)
+            # Compute effective span for trend
+            range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
+            range_end = dt_to if date_to else datetime.now(timezone.utc)
+            effective_days = max((range_end - range_start).days, 1)
+        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)
+            non_date_filter.append(PrintArchive.printer_id == printer_id)
         if project_id:
-            base_filter.append(PrintArchive.project_id == project_id)
+            non_date_filter.append(PrintArchive.project_id == project_id)
+        base_filter.extend(non_date_filter)
 
         # Total counts
         total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
@@ -141,15 +160,16 @@ class FailureAnalysisService:
 
         # Failure rate trend (by week)
         trend_data = []
-        for i in range(min(days // 7, 12)):  # Up to 12 weeks
+        num_weeks = max(effective_days // 7, 1)
+        for i in range(num_weeks):
             week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
             week_start = week_end - timedelta(weeks=1)
 
-            week_filter = base_filter.copy()
-            week_filter[0] = and_(
+            week_filter = [
                 PrintArchive.created_at >= week_start,
                 PrintArchive.created_at < week_end,
-            )
+                *non_date_filter,
+            ]
 
             week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
             week_failed = await self.db.execute(
@@ -174,7 +194,7 @@ class FailureAnalysisService:
         trend_data.reverse()  # Oldest first
 
         return {
-            "period_days": days,
+            "period_days": effective_days,
             "total_prints": total_prints,
             "failed_prints": failed_prints,
             "failure_rate": round(failure_rate, 1),

+ 150 - 0
backend/tests/integration/test_archives_api.py

@@ -227,6 +227,156 @@ class TestArchivesAPI:
         assert "successful_prints" in result
 
 
+class TestArchivesSlimAPI:
+    """Integration tests for /api/v1/archives/slim endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_empty(self, async_client: AsyncClient):
+        """Verify empty list when no archives exist."""
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_returns_only_expected_fields(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify response contains only slim fields, not full archive data."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Slim Test",
+            status="completed",
+            filament_type="PLA",
+            filament_color="#FF0000",
+            filament_used_grams=50.0,
+            print_time_seconds=3600,
+            cost=1.50,
+            quantity=2,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        item = data[0]
+
+        # Expected fields present
+        assert item["printer_id"] == printer.id
+        assert item["print_name"] == "Slim Test"
+        assert item["status"] == "completed"
+        assert item["filament_type"] == "PLA"
+        assert item["filament_color"] == "#FF0000"
+        assert item["filament_used_grams"] == 50.0
+        assert item["print_time_seconds"] == 3600
+        assert item["cost"] == 1.50
+        assert item["quantity"] == 2
+        assert "created_at" in item
+
+        # Full archive fields must NOT be present
+        assert "id" not in item
+        assert "filename" not in item
+        assert "file_path" not in item
+        assert "file_size" not in item
+        assert "extra_data" not in item
+        assert "notes" not in item
+        assert "tags" not in item
+        assert "photos" not in item
+        assert "thumbnail_path" not in item
+        assert "content_hash" not in item
+        assert "duplicates" not in item
+        assert "duplicate_count" not in item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_computes_actual_time(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is computed from started_at/completed_at."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
+        completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)  # 2 hours = 7200s
+        await archive_factory(
+            printer.id,
+            status="completed",
+            started_at=started,
+            completed_at=completed,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] == 7200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_actual_time_null_for_failed(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is null for non-completed prints."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="failed",
+            started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify date_from and date_to filters work."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Old Print",
+            created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
+        )
+        await archive_factory(
+            printer.id,
+            print_name="New Print",
+            created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
+        )
+
+        # Filter to only June 2024
+        response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["print_name"] == "New Print"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify limit and offset work."""
+        printer = await printer_factory()
+        for i in range(5):
+            await archive_factory(printer.id, print_name=f"Print {i}")
+
+        response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
+
+        assert response.status_code == 200
+        assert len(response.json()) == 2
+
+
 class TestArchiveDataIntegrity:
     """Tests for archive data integrity."""
 

+ 204 - 14
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -41,8 +41,70 @@ const mockPrinters = [
 ];
 
 const mockArchives = [
-  { id: 1, created_at: '2024-01-01T00:00:00Z', print_name: 'Test Print 1' },
-  { id: 2, created_at: '2024-01-02T00:00:00Z', print_name: 'Test Print 2' },
+  {
+    id: 1,
+    created_at: '2024-01-01T10:00:00Z',
+    started_at: '2024-01-01T10:00:00Z',
+    completed_at: '2024-01-01T14:30:00Z',
+    print_name: 'Benchy',
+    status: 'completed',
+    printer_id: 1,
+    filament_type: 'PLA',
+    filament_color: '#00FF00',
+    filament_used_grams: 25,
+    actual_time_seconds: 16200,
+    print_time_seconds: 15000,
+    cost: 0.75,
+    quantity: 1,
+  },
+  {
+    id: 2,
+    created_at: '2024-01-02T14:00:00Z',
+    started_at: '2024-01-02T14:00:00Z',
+    completed_at: '2024-01-02T22:00:00Z',
+    print_name: 'Large Vase',
+    status: 'completed',
+    printer_id: 1,
+    filament_type: 'PETG',
+    filament_color: '#FF0000',
+    filament_used_grams: 180,
+    actual_time_seconds: 28800,
+    print_time_seconds: 27000,
+    cost: 5.40,
+    quantity: 1,
+  },
+  {
+    id: 3,
+    created_at: '2024-01-03T08:00:00Z',
+    started_at: '2024-01-03T08:00:00Z',
+    completed_at: null,
+    print_name: 'Failed Bracket',
+    status: 'failed',
+    printer_id: 2,
+    filament_type: 'ABS',
+    filament_color: '#0000FF',
+    filament_used_grams: 10,
+    actual_time_seconds: 3600,
+    print_time_seconds: 7200,
+    cost: 0.30,
+    quantity: 1,
+  },
+  {
+    id: 4,
+    created_at: '2024-01-03T20:00:00Z',
+    started_at: '2024-01-03T20:00:00Z',
+    completed_at: '2024-01-04T02:00:00Z',
+    print_name: 'Phone Stand',
+    status: 'completed',
+    printer_id: 2,
+    filament_type: 'PLA',
+    filament_color: '#00FF00',
+    filament_used_grams: 45,
+    actual_time_seconds: 21600,
+    print_time_seconds: 20000,
+    cost: 1.35,
+    quantity: 1,
+  },
 ];
 
 const mockSettings = {
@@ -60,9 +122,19 @@ const mockFailureAnalysis = {
     'First layer adhesion': 3,
     'Filament runout': 2,
   },
+  failures_by_filament: {
+    'ABS': 3,
+    'PLA': 2,
+  },
+  failures_by_printer: {
+    '1': 2,
+    '2': 3,
+  },
+  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 },
   ],
 };
 
@@ -75,13 +147,13 @@ describe('StatsPage', () => {
       http.get('/api/v1/printers/', () => {
         return HttpResponse.json(mockPrinters);
       }),
-      http.get('/api/v1/archives/', () => {
+      http.get('/api/v1/archives/slim', () => {
         return HttpResponse.json(mockArchives);
       }),
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json(mockSettings);
       }),
-      http.get('/api/v1/stats/failure-analysis', () => {
+      http.get('/api/v1/archives/analysis/failures', () => {
         return HttpResponse.json(mockFailureAnalysis);
       })
     );
@@ -127,7 +199,7 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Filament Used')).toBeInTheDocument();
-        expect(screen.getByText('5.50kg')).toBeInTheDocument();
+        expect(screen.getByText('5.5kg')).toBeInTheDocument();
       });
     });
   });
@@ -138,7 +210,7 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Success Rate')).toBeInTheDocument();
-        // Success rate should be calculated: 140/150 = 93%
+        // Success rate: 140/(140+10) = 93%
         expect(screen.getByText('93%')).toBeInTheDocument();
       });
     });
@@ -163,27 +235,145 @@ describe('StatsPage', () => {
   });
 
   describe('widgets', () => {
-    it('shows filament types widget', async () => {
+    it('shows time accuracy widget', async () => {
       render(<StatsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Filament Types')).toBeInTheDocument();
+        expect(screen.getByText('Time Accuracy')).toBeInTheDocument();
       });
     });
 
-    it('shows time accuracy widget', async () => {
+    it('shows print activity widget', async () => {
       render(<StatsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Time Accuracy')).toBeInTheDocument();
+        expect(screen.getByText('Print Activity')).toBeInTheDocument();
       });
     });
 
-    it('shows print activity widget', async () => {
+    it('shows failure analysis widget', async () => {
       render(<StatsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Print Activity')).toBeInTheDocument();
+        expect(screen.getByText('Failure Analysis')).toBeInTheDocument();
+      });
+    });
+
+    it('shows printer stats widget', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer Stats')).toBeInTheDocument();
+      });
+    });
+
+    it('shows filament trends widget', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+      });
+    });
+
+    it('shows records widget', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Records')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('printer stats sub-cards', () => {
+    it('shows prints by printer section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Prints by Printer')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print duration section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Duration')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print habits section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Habits')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print time of day section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Time of Day')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('filament trends sub-cards', () => {
+    it('shows by material section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('By Material')).toBeInTheDocument();
+      });
+    });
+
+    it('shows success by material section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Success by Material')).toBeInTheDocument();
+      });
+    });
+
+    it('shows color distribution section', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Color Distribution')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('records widget', () => {
+    it('shows longest print record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Longest Print')).toBeInTheDocument();
+      });
+    });
+
+    it('shows heaviest print record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Heaviest Print')).toBeInTheDocument();
+      });
+    });
+
+    it('shows most expensive record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Most Expensive')).toBeInTheDocument();
+      });
+    });
+
+    it('shows success streak record', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Success Streak')).toBeInTheDocument();
       });
     });
   });

+ 38 - 4
frontend/src/api/client.ts

@@ -373,6 +373,22 @@ export interface Archive {
   created_by_username: string | null;
 }
 
+export interface ArchiveSlim {
+  printer_id: number | null;
+  print_name: string | null;
+  print_time_seconds: number | null;
+  actual_time_seconds: number | null;
+  filament_used_grams: number | null;
+  filament_type: string | null;
+  filament_color: string | null;
+  status: string;
+  started_at: string | null;
+  completed_at: string | null;
+  cost: number | null;
+  quantity: number;
+  created_at: string;
+}
+
 export interface PrintLogEntry {
   id: number;
   print_name: string | null;
@@ -2489,14 +2505,23 @@ 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}`);
   },
+  getArchivesSlim: (dateFrom?: string, dateTo?: string) => {
+    const params = new URLSearchParams();
+    if (dateFrom) params.set('date_from', dateFrom);
+    if (dateTo) params.set('date_to', dateTo);
+    const qs = params.toString();
+    return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
+  },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   searchArchives: (query: string, options?: {
     printerId?: number;
@@ -2536,7 +2561,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,12 +2581,15 @@ 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}`);
+    const qs = params.toString();
+    return request<FailureAnalysis>(`/archives/analysis/failures${qs ? `?${qs}` : ''}`);
   },
   compareArchives: (archiveIds: number[]) =>
     request<ArchiveComparison>(`/archives/compare?archive_ids=${archiveIds.join(',')}`),

+ 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 {

+ 290 - 131
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,37 @@ import {
   CartesianGrid,
   Tooltip,
   ResponsiveContainer,
-  BarChart,
-  Bar,
   PieChart,
   Pie,
   Cell,
-  Legend,
 } from 'recharts';
-import type { Archive } from '../api/client';
+import type { ArchiveSlim } from '../api/client';
+import { MetricToggle, type Metric } from './MetricToggle';
 import { parseUTCDate } from '../utils/date';
+import { formatWeight } from '../utils/weight';
 
 interface FilamentTrendsProps {
-  archives: Archive[];
+  archives: ArchiveSlim[];
   currency?: string;
+  dateFrom?: string;
+  dateTo?: 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');
+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'];
 
-  // 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]);
+export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: FilamentTrendsProps) {
+  const { t } = useTranslation();
+  const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');
+  const [colorMetric, setColorMetric] = useState<Metric>('weight');
 
   // 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 +56,69 @@ 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
+  // 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<string, { date: string; filament: number; cost: number; prints: number }>();
+    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 (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 +129,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 +148,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 }>();
+
+    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;
 
-      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),
+      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 = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
+  const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
+  const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
+  const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);
+  const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
 
+  return (
+    <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 +276,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 +319,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 +344,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 +354,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 +374,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 +386,115 @@ 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>
+            <MetricToggle value={colorMetric} onChange={setColorMetric} exclude={['time']} />
+          </div>
+          {colorData.length > 0 ? (() => {
+            const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0);
+            return (
+              <div>
+                <div className="relative mx-auto" style={{ width: 160, height: 160 }}>
+                  <ResponsiveContainer width="100%" height="100%">
+                    <PieChart>
+                      <Pie
+                        data={colorData}
+                        cx="50%"
+                        cy="50%"
+                        innerRadius={45}
+                        outerRadius={70}
+                        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="absolute inset-0 flex flex-col items-center justify-center">
+                    <span className="text-lg font-bold text-white">
+                      {colorMetric === 'weight' ? formatWeight(colorTotal) : colorTotal}
+                    </span>
+                    <span className="text-[10px] text-bambu-gray">
+                      {colorData.length} {colorData.length === 1 ? 'color' : 'colors'}
+                    </span>
+                  </div>
+                </div>
+                <div className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
+                  {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-1.5 text-xs min-w-0">
+                        <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-white/20"
+                          style={{ backgroundColor: entry.hex }} />
+                        <span className="text-bambu-gray truncate">
+                          {percent}%
+                        </span>
+                      </div>
+                    );
+                  })}
+                </div>
+                {colorData.length > 8 && (
+                  <p className="text-[10px] text-bambu-gray mt-1 text-center">+{colorData.length - 8} more</p>
+                )}
+              </div>
+            );
+          })() : (
+            <div className="h-[160px] flex items-center justify-center text-bambu-gray">
+              {t('stats.noColorData')}
+            </div>
+          )}
         </div>
       </div>
     </div>

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

@@ -0,0 +1,39 @@
+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;
+  exclude?: Metric[];
+}
+
+export function MetricToggle({ value, onChange, exclude }: MetricToggleProps) {
+  const { t } = useTranslation();
+
+  const labels: Record<Metric, string> = {
+    weight: t('stats.filamentByWeight'),
+    prints: t('stats.filamentByPrints'),
+    time: t('stats.filamentByTime'),
+  };
+
+  const metrics = exclude ? METRICS.filter(m => !exclude.includes(m)) : METRICS;
+
+  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

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 611 - 184
frontend/src/pages/StatsPage.tsx


+ 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`;
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio