diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index c783fbf41..9ae28b326 100644 --- a/backend/app/api/routes/archives.py +++ b/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( diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 4ba8da812..6dae2ae67 100644 --- a/backend/app/schemas/archive.py +++ b/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 diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 859b45844..8c4430fcb 100644 --- a/backend/app/services/archive.py +++ b/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()) diff --git a/backend/app/services/failure_analysis.py b/backend/app/services/failure_analysis.py index 348a98464..6a78bcbaf 100644 --- a/backend/app/services/failure_analysis.py +++ b/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), diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 59c8a30d4..f3afa1ae6 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/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.""" diff --git a/frontend/src/__tests__/pages/StatsPage.test.tsx b/frontend/src/__tests__/pages/StatsPage.test.tsx index fdeaf501e..1a86462e0 100644 --- a/frontend/src/__tests__/pages/StatsPage.test.tsx +++ b/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,14 +235,6 @@ describe('StatsPage', () => { }); describe('widgets', () => { - it('shows filament types widget', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Filament Types')).toBeInTheDocument(); - }); - }); - it('shows time accuracy widget', async () => { render(); @@ -186,6 +250,132 @@ describe('StatsPage', () => { expect(screen.getByText('Print Activity')).toBeInTheDocument(); }); }); + + it('shows failure analysis widget', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Failure Analysis')).toBeInTheDocument(); + }); + }); + + it('shows printer stats widget', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Printer Stats')).toBeInTheDocument(); + }); + }); + + it('shows filament trends widget', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Filament Trends')).toBeInTheDocument(); + }); + }); + + it('shows records widget', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Records')).toBeInTheDocument(); + }); + }); + }); + + describe('printer stats sub-cards', () => { + it('shows prints by printer section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Prints by Printer')).toBeInTheDocument(); + }); + }); + + it('shows print duration section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Print Duration')).toBeInTheDocument(); + }); + }); + + it('shows print habits section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Print Habits')).toBeInTheDocument(); + }); + }); + + it('shows print time of day section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Print Time of Day')).toBeInTheDocument(); + }); + }); + }); + + describe('filament trends sub-cards', () => { + it('shows by material section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('By Material')).toBeInTheDocument(); + }); + }); + + it('shows success by material section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Success by Material')).toBeInTheDocument(); + }); + }); + + it('shows color distribution section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Color Distribution')).toBeInTheDocument(); + }); + }); + }); + + describe('records widget', () => { + it('shows longest print record', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Longest Print')).toBeInTheDocument(); + }); + }); + + it('shows heaviest print record', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Heaviest Print')).toBeInTheDocument(); + }); + }); + + it('shows most expensive record', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Most Expensive')).toBeInTheDocument(); + }); + }); + + it('shows success streak record', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Success Streak')).toBeInTheDocument(); + }); + }); }); describe('export', () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3d26ec387..6e9a67b9d 100644 --- a/frontend/src/api/client.ts +++ b/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(`/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(`/archives/slim${qs ? `?${qs}` : ''}`); + }, getArchive: (id: number) => request(`/archives/${id}`), searchArchives: (query: string, options?: { printerId?: number; @@ -2536,7 +2561,13 @@ export const api = { request(`/archives/${id}/favorite`, { method: 'POST' }), deleteArchive: (id: number) => request(`/archives/${id}`, { method: 'DELETE' }), - getArchiveStats: () => request('/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(`/archives/stats${qs ? `?${qs}` : ''}`); + }, // Tag management getTags: () => request('/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(`/archives/analysis/failures?${params}`); + const qs = params.toString(); + return request(`/archives/analysis/failures${qs ? `?${qs}` : ''}`); }, compareArchives: (archiveIds: number[]) => request(`/archives/compare?archive_ids=${archiveIds.join(',')}`), diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 73b075530..244d1dabf 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/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 { diff --git a/frontend/src/components/FilamentTrends.tsx b/frontend/src/components/FilamentTrends.tsx index 725625b10..694f0ddfe 100644 --- a/frontend/src/components/FilamentTrends.tsx +++ b/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); - } -} +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const HOUR_SUFFIXES = ['12am', '1am', '2am', '3am', '4am', '5am', '6am', '7am', '8am', '9am', '10am', '11am', '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', '6pm', '7pm', '8pm', '9pm', '10pm', '11pm']; -export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) { - const [timeRange, setTimeRange] = useState('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]); +export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: FilamentTrendsProps) { + const { t } = useTranslation(); + const [filamentTypeMetric, setFilamentTypeMetric] = useState('weight'); + const [colorMetric, setColorMetric] = useState('weight'); // Calculate daily usage data const dailyData = useMemo(() => { const dataMap = new Map(); - 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(); + 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(); - 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(); - 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]); - - // Monthly comparison data - const monthlyComparison = useMemo(() => { - const now = new Date(); - const months: { month: string; filament: number; cost: number; prints: number }[] = []; - - 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' }); - - const monthArchives = archives.filter(a => { - const d = parseUTCDate(a.completed_at || a.created_at) || new Date(0); - return d >= monthDate && d <= monthEnd; - }); - - 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), - }); - } - - return months; }, [archives]); - 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); + // Usage by filament type (print count) + const filamentTypePrintData = useMemo(() => { + const dataMap = new Map(); + archives.forEach(archive => { + const type = archive.filament_type || 'Unknown'; + const types = type.split(', '); + types.forEach(t => { + dataMap.set(t, (dataMap.get(t) || 0) + 1); + }); + }); + return Array.from(dataMap.entries()) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); + }, [archives]); + + // Usage by filament type (print time in hours) + const filamentTypeTimeData = useMemo(() => { + const dataMap = new Map(); + archives.forEach(archive => { + const type = archive.filament_type || 'Unknown'; + const types = type.split(', '); + const seconds = (archive.actual_time_seconds || archive.print_time_seconds || 0) / types.length; + types.forEach(t => { + dataMap.set(t, (dataMap.get(t) || 0) + seconds); + }); + }); + return Array.from(dataMap.entries()) + .map(([name, seconds]) => ({ name, value: Math.round((seconds / 3600) * 10) / 10 })) + .sort((a, b) => b.value - a.value); + }, [archives]); + + // Success rate by filament type + const filamentSuccessData = useMemo(() => { + const map = new Map(); + archives.forEach(a => { + if (a.status !== 'completed' && a.status !== 'failed') return; + const types = (a.filament_type || 'Unknown').split(', '); + types.forEach(type => { + const entry = map.get(type) || { completed: 0, failed: 0 }; + if (a.status === 'completed') entry.completed++; + else entry.failed++; + map.set(type, entry); + }); + }); + return Array.from(map.entries()) + .filter(([, v]) => v.completed + v.failed >= 2) + .map(([name, v]) => { + const total = v.completed + v.failed; + const rate = Math.round((v.completed / total) * 100); + return { name, rate, total }; + }) + .sort((a, b) => b.rate - a.rate); + }, [archives]); + + // Color distribution + const colorData = useMemo(() => { + const colorMap = new Map(); + + archives.forEach(a => { + if (!a.filament_color) return; + const colors = a.filament_color.split(',').map(c => c.trim()); + const weightPerColor = (a.filament_used_grams || 0) / colors.length; + + colors.forEach(hex => { + const entry = colorMap.get(hex) || { count: 0, weight: 0 }; + entry.count++; + entry.weight += weightPerColor; + colorMap.set(hex, entry); + }); + }); + + return Array.from(colorMap.entries()) + .map(([hex, data]) => ({ + hex, + value: colorMetric === 'prints' ? data.count : Math.round(data.weight), + })) + .sort((a, b) => b.value - a.value); + }, [archives, colorMetric]); + + const activeFilamentTypeData = + filamentTypeMetric === 'weight' ? filamentTypeData : + filamentTypeMetric === 'prints' ? filamentTypePrintData : + filamentTypeTimeData; + + const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData; + const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0); + const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0); + const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0); + const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size; return ( -
- {/* Time Range Selector */} -
-

Filament Usage Trends

-
- {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => ( - - ))} -
-
- +
{/* Summary Cards */} -
+
-
-

Period Filament

-

{(totalFilament / 1000).toFixed(2)}kg

+
+

{t('stats.periodFilament')}

+

{formatWeight(totalFilament)}

-

{totalFilament.toFixed(0)}g total

+

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

-
-

Period Cost

+
+

{t('stats.periodCost')}

{currency}{totalCost.toFixed(2)}

-

{totalPrints} prints

+

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

-
-

Avg per Print

+
+

{t('stats.avgPerPrint')}

{totalPrints > 0 ? (totalFilament / totalPrints).toFixed(0) @@ -210,7 +276,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps {/* Usage Over Time Chart */} {chartData.length > 0 ? (

-

Usage Over Time

+

{t('stats.usageOverTime')}

@@ -253,21 +319,24 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
) : (
- No data for selected time range + {t('stats.noPrintDataInRange')}
)} {/* Bottom Charts */} -
+
{/* Filament Type Distribution */}
-

By Filament Type

- {filamentTypeData.length > 0 ? ( +
+

{t('stats.byMaterial')}

+ +
+ {activeFilamentTypeData.length > 0 ? (
- {filamentTypeData.map((_, index) => ( + {activeFilamentTypeData.map((_, index) => ( ))} @@ -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', + ]} />
- {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 (
@@ -300,7 +374,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps style={{ backgroundColor: COLORS[index % COLORS.length] }} /> {entry.name} - {percent}% + + {filamentTypeMetric === 'weight' ? formatWeight(entry.value) : + filamentTypeMetric === 'time' ? `${entry.value}h` : + entry.value} · {percent}% +
); })} @@ -308,34 +386,115 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
) : (
- No filament data + {t('stats.noFilamentData')}
)}
- {/* Monthly Comparison */} + {/* Success by Material */}
-

Monthly Comparison

- - - - - `${v}g`} /> - [ - name === 'filament' ? `${value ?? 0}g` : name === 'cost' ? `${currency}${Number(value ?? 0).toFixed(2)}` : value ?? 0, - name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints' - ]} - /> - - - - +

{t('stats.filamentSuccess')}

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

{t('stats.colorDistribution')}

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

+{colorData.length - 8} more

+ )} +
+ ); + })() : ( +
+ {t('stats.noColorData')} +
+ )}
diff --git a/frontend/src/components/MetricToggle.tsx b/frontend/src/components/MetricToggle.tsx new file mode 100644 index 000000000..e508969a3 --- /dev/null +++ b/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 = { + weight: t('stats.filamentByWeight'), + prints: t('stats.filamentByPrints'), + time: t('stats.filamentByTime'), + }; + + const metrics = exclude ? METRICS.filter(m => !exclude.includes(m)) : METRICS; + + return ( +
+ {metrics.map(m => ( + + ))} +
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 5bb58440f..d38142e80 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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 diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 00a3a2927..67a95953e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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 diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index cc98228c7..78dcd0b04 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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 diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 287475ced..466fd5561 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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 diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index c50087804..9f10ea080 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: 'メンテナンス', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index acca27e5e..4de74d2a9 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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 diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 9a88c0fe8..b7712149a 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/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 ArchiveSlim } 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,64 +130,31 @@ function QuickStatsWidget({ total_energy_cost: number; } | undefined; currency: string; - t: (key: string) => string; }) { + const { t } = useTranslation(); + + const items = [ + { icon: Package, color: 'text-bambu-green', label: t('stats.totalPrints'), value: `${stats?.total_prints || 0}` }, + { icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours?.toFixed(1) ?? '0'}h` }, + { icon: Package, color: 'text-orange-400', label: t('stats.filamentUsed'), value: formatWeight(stats?.total_filament_grams || 0) }, + { icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost?.toFixed(2) ?? '0.00'}` }, + { icon: Zap, color: 'text-yellow-400', label: t('stats.energyUsed'), value: `${stats?.total_energy_kwh?.toFixed(3) ?? '0.000'} kWh` }, + { icon: DollarSign, color: 'text-yellow-500', label: t('stats.energyCost'), value: `${currency} ${stats?.total_energy_cost?.toFixed(2) ?? '0.00'}` }, + ]; + return (
-
-
- + {items.map((item) => ( +
+
+ +
+
+

{item.label}

+

{item.value}

+
-
-

{t('stats.totalPrints')}

-

{stats?.total_prints || 0}

-
-
-
-
- -
-
-

{t('stats.printTime')}

-

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

-
-
-
-
- -
-
-

{t('stats.filamentUsed')}

-

{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg

-
-
-
-
- -
-
-

{t('stats.filamentCost')}

-

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

-
-
-
-
- -
-
-

{t('stats.energyUsed')}

-

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

-
-
-
-
- -
-
-

{t('stats.energyCost')}

-

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

-
-
+ ))}
); } @@ -111,7 +163,6 @@ function SuccessRateWidget({ stats, printerMap, size = 1, - t, }: { stats: { total_prints: number; @@ -121,10 +172,11 @@ function SuccessRateWidget({ } | undefined; printerMap: Map; size?: 1 | 2 | 4; - t: (key: string) => string; }) { - const successRate = stats?.total_prints - ? Math.round((stats.successful_prints / stats.total_prints) * 100) + const { t } = useTranslation(); + const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0); + const successRate = completedAndFailed + ? Math.round((stats!.successful_prints / completedAndFailed) * 100) : 0; // Scale gauge size based on widget size @@ -197,7 +249,6 @@ function TimeAccuracyWidget({ stats, printerMap, size = 1, - t, }: { stats: { average_time_accuracy: number | null; @@ -205,8 +256,8 @@ function TimeAccuracyWidget({ } | undefined; printerMap: Map; size?: 1 | 2 | 4; - t: (key: string) => string; }) { + const { t } = useTranslation(); const accuracy = stats?.average_time_accuracy; if (accuracy === null || accuracy === undefined) { @@ -299,81 +350,103 @@ function TimeAccuracyWidget({ ); } -function FilamentTypesWidget({ - stats, - size = 1, - t, -}: { - stats: { - total_prints: number; - prints_by_filament_type: Record; - } | undefined; - size?: 1 | 2 | 4; - t: (key: string, options?: Record) => string; -}) { - if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) { - return

{t('stats.noFilamentData')}

; - } +function HourlyHeatmap({ printDates, dateFrom, dateTo }: { printDates: string[]; dateFrom: string; dateTo: string }) { + const { days, hourlyCounts, maxCount } = useMemo(() => { + const start = new Date(dateFrom + 'T00:00:00'); + const end = new Date(dateTo + 'T00:00:00'); + const days: { key: string; label: string }[] = []; + const fmtLocal = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const current = new Date(start); + while (current <= end) { + days.push({ + key: fmtLocal(current), + label: current.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }), + }); + current.setDate(current.getDate() + 1); + } - // Sort by print count descending - const sortedEntries = Object.entries(stats.prints_by_filament_type).sort( - ([, a], [, b]) => b - a - ); + // Count prints per (day, hour) + const counts: Record = {}; + let max = 0; + printDates.forEach(d => { + const date = parseUTCDate(d); + if (!date) return; + const dayKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const k = `${dayKey}-${date.getHours()}`; + counts[k] = (counts[k] || 0) + 1; + if (counts[k] > max) max = counts[k]; + }); - // 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; + return { days, hourlyCounts: counts, maxCount: Math.max(1, max) }; + }, [printDates, dateFrom, dateTo]); - // Use grid layout when expanded - if (size === 4 && displayEntries.length > 4) { - return ( -
- {displayEntries.map(([type, count]) => { - const percentage = Math.round((count / (stats.total_prints || 1)) * 100); - return ( -
-
- {type} - {count} -
-
-
-
-
- ); - })} -
- ); - } + const getColor = (count: number) => { + if (count === 0) return 'bg-bambu-dark'; + const intensity = count / maxCount; + if (intensity <= 0.25) return 'bg-bambu-green/30'; + if (intensity <= 0.5) return 'bg-bambu-green/50'; + if (intensity <= 0.75) return 'bg-bambu-green/75'; + return 'bg-bambu-green'; + }; + + const cellSize = 20; + const gap = 2; + + const dayLabelWidth = 80; return ( -
- {displayEntries.map(([type, count]) => { - const percentage = Math.round((count / (stats.total_prints || 1)) * 100); - return ( -
-
- {type} - {count} {t('common.prints')} +
+
+ {/* Hour labels row */} +
+ {HOUR_LABELS.map((label, i) => ( +
+ {label}
-
-
+ ))} +
+ + {/* Day rows */} + {days.map(day => ( +
+
+ {day.label}
+ {Array.from({ length: 24 }, (_, hour) => { + const count = hourlyCounts[`${day.key}-${hour}`] || 0; + return ( +
+ ); + })}
- ); - })} - {hasMore && ( -

- {t('common.more', { count: sortedEntries.length - maxEntries })} -

- )} + ))} +
+ + {/* Legend */} +
+ Less +
+
+
+
+
+
+
+ More +
); } @@ -381,43 +454,236 @@ function FilamentTypesWidget({ function PrintActivityWidget({ printDates, size = 2, + dateFrom, + dateTo, }: { printDates: string[]; size?: 1 | 2 | 4; + dateFrom?: string; + dateTo?: string; }) { - // Show more months when widget is larger - cell size auto-calculated - const months = size === 1 ? 3 : size === 2 ? 6 : 12; + const spanDays = useMemo(() => { + if (dateFrom && dateTo) { + return Math.max((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / 86400000, 0) + 1; + } + if (dateFrom) { + return Math.max((Date.now() - new Date(dateFrom).getTime()) / 86400000, 0) + 1; + } + return Infinity; + }, [dateFrom, dateTo]); + + if (spanDays <= 7 && dateFrom && dateTo) { + return ; + } + + // Calculate months from the timeframe span, fall back to size-based default for all-time + const sizeDefault = size === 1 ? 3 : size === 2 ? 6 : 12; + const months = spanDays === Infinity + ? sizeDefault + : Math.max(1, Math.ceil(spanDays / 30)); return ; } -function PrintsByPrinterWidget({ +function PrinterStatsWidget({ stats, + archives, printerMap, - t, }: { stats: { prints_by_printer: Record } | undefined; + archives: ArchiveSlim[]; printerMap: Map; - t: (key: string) => string; }) { - if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) { - return

{t('stats.noPrinterData')}

; - } + const { t } = useTranslation(); + const [printerMetric, setPrinterMetric] = useState('weight'); + const [habitsMetric, setHabitsMetric] = useState('weight'); + + // Per-printer data + const printerData = useMemo(() => { + const map = new Map(); + if (stats?.prints_by_printer) { + Object.entries(stats.prints_by_printer).forEach(([id, count]) => { + const entry = map.get(id) || { prints: 0, weight: 0, time: 0 }; + entry.prints = count; + map.set(id, entry); + }); + } + archives.forEach(a => { + if (!a.printer_id) return; + const id = String(a.printer_id); + const entry = map.get(id) || { prints: 0, weight: 0, time: 0 }; + entry.weight += a.filament_used_grams || 0; + entry.time += a.actual_time_seconds || a.print_time_seconds || 0; + if (!stats?.prints_by_printer) entry.prints++; + map.set(id, entry); + }); + return Array.from(map.entries()) + .map(([id, v]) => ({ + name: printerMap.get(id) || `${t('common.printer')} ${id}`, + value: printerMetric === 'prints' ? v.prints : + printerMetric === 'weight' ? Math.round(v.weight) : + Math.round((v.time / 3600) * 10) / 10, + })) + .sort((a, b) => b.value - a.value); + }, [stats, archives, printerMap, printerMetric, t]); + + // Hourly distribution (time of day) + const hourlyData = useMemo(() => { + const hours = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + label: HOUR_LABELS[i], + total: 0, + failures: 0, + })); + + archives.forEach(a => { + if (!a.started_at) return; + const date = parseUTCDate(a.started_at); + if (!date) return; + const h = date.getHours(); + hours[h].total++; + if (a.status === 'failed') { + hours[h].failures++; + } + }); + + return hours; + }, [archives]); + + // Duration distribution + const durationData = useMemo(() => { + const counts = DURATION_BUCKETS.map(b => ({ name: b.key, count: 0 })); + archives.forEach(a => { + const seconds = a.actual_time_seconds || a.print_time_seconds; + if (!seconds || seconds <= 0) return; + for (let i = 0; i < DURATION_BUCKETS.length; i++) { + if (seconds <= DURATION_BUCKETS[i].max) { + counts[i].count++; + break; + } + } + }); + return counts; + }, [archives]); + + // Habits (avg per day-of-week) + const habitsData = useMemo(() => { + const dayValues = [0, 0, 0, 0, 0, 0, 0]; + const weeksSet = new Set(); + archives.forEach(a => { + const date = parseUTCDate(a.created_at) || new Date(a.created_at); + let day = date.getDay() - 1; + if (day < 0) day = 6; + if (habitsMetric === 'prints') dayValues[day]++; + else if (habitsMetric === 'weight') dayValues[day] += a.filament_used_grams || 0; + else dayValues[day] += (a.actual_time_seconds || a.print_time_seconds || 0) / 3600; + const weekStart = new Date(date); + weekStart.setDate(date.getDate() - ((date.getDay() + 6) % 7)); + weeksSet.add(`${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`); + }); + const numWeeks = Math.max(weeksSet.size, 1); + return DAY_LABELS.map((name, i) => ({ + name, + avg: Math.round((dayValues[i] / numWeeks) * 10) / 10, + })); + }, [archives, habitsMetric]); + + const metricStyle = (m: Metric) => ({ + unit: m === 'weight' ? 'g' : m === 'time' ? 'h' : '', + color: m === 'weight' ? '#00ae42' : m === 'time' ? '#3b82f6' : '#f59e0b', + }); + const ps = metricStyle(printerMetric); + const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints'); + const hs = metricStyle(habitsMetric); + const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints'); return ( -
- {Object.entries(stats.prints_by_printer).map(([printerId, count]) => ( -
-
- -
-
-

- {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`} -

-

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

-
+
+ {/* By Printer */} +
+
+

{t('stats.printsByPrinter')}

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

{t('stats.noPrinterData')}

+ )} +
+ +
+ {/* Print Duration */} +
+

{t('stats.printDuration')}

+ {archives.length > 0 ? ( + + + + + + + + + + ) : ( +

{t('stats.noArchiveData')}

+ )} +
+ + {/* Print Habits */} +
+
+

{t('stats.printHabits')}

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

{t('stats.noArchiveData')}

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

{t('stats.printTimeOfDay')}

+ {archives.length > 0 ? ( + + + + + + + + + + + ) : ( +

{t('stats.noArchiveData')}

+ )} +
+
); } @@ -425,22 +691,33 @@ function PrintsByPrinterWidget({ function FilamentTrendsWidget({ archives, currency, - t, + dateFrom, + dateTo, }: { archives: Parameters[0]['archives']; currency: string; - t: (key: string) => string; + dateFrom?: string; + dateTo?: string; }) { + const { t } = useTranslation(); if (!archives || archives.length === 0) { return

{t('stats.noPrintData')}

; } - return ; + return ; } -function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record) => 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) { @@ -452,7 +729,7 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str } if (!analysis || analysis.total_prints === 0) { - return

{t('stats.noPrintDataLast30Days')}

; + return

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

; } // Show more reasons when expanded @@ -518,6 +795,131 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str ); } +function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; 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; + + // Find the archive with the highest value for a given field + const findMax = (getter: (a: ArchiveSlim) => number | null | undefined): { archive: ArchiveSlim | null; value: number } => { + let best: ArchiveSlim | null = null; + let bestVal = 0; + archives.forEach(a => { + const v = getter(a); + if (v && v > bestVal) { bestVal = v; best = a; } + }); + return { archive: best, value: bestVal }; + }; + + const longest = findMax(a => a.actual_time_seconds); + if (longest.archive) { + result.push({ + icon: Clock, iconColor: 'text-blue-400', label: t('stats.longestPrint'), + value: formatDuration(longest.value), + detail: longest.archive.print_name || null, + }); + } + + const heaviest = findMax(a => a.filament_used_grams); + if (heaviest.archive) { + result.push({ + icon: Package, iconColor: 'text-orange-400', label: t('stats.heaviestPrint'), + value: formatWeight(heaviest.value), + detail: heaviest.archive.print_name || null, + }); + } + + const costliest = findMax(a => a.cost); + if (costliest.archive) { + result.push({ + icon: DollarSign, iconColor: 'text-green-400', label: t('stats.mostExpensivePrint'), + value: `${currency}${costliest.value.toFixed(2)}`, + detail: costliest.archive.print_name || null, + }); + } + + // Busiest day + const dayCounts = new Map(); + archives.forEach(a => { + const date = parseUTCDate(a.created_at) || new Date(a.created_at); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + dayCounts.set(key, (dayCounts.get(key) || 0) + 1); + }); + let busiestDay = ''; + let busiestCount = 0; + dayCounts.forEach((count, day) => { + if (count > busiestCount) { + busiestCount = count; + busiestDay = day; + } + }); + if (busiestCount > 1) { + result.push({ + icon: Calendar, + iconColor: 'text-purple-400', + label: t('stats.busiestDay'), + value: `${busiestCount} ${t('common.prints')}`, + detail: (() => { const [y, m, d] = busiestDay.split('-').map(Number); return new Date(y, m - 1, d).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.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime()); + let streak = 0; + for (const a of sorted) { + if (a.status === 'completed') streak++; + else break; + } + if (streak > 0) { + result.push({ + icon: Zap, + iconColor: 'text-yellow-400', + label: t('stats.successStreak'), + value: `${streak}`, + detail: streak === 1 ? t('stats.streakPrint') : t('stats.streakPrints', { count: streak }), + }); + } + + return result; + }, [archives, currency, t]); + + if (records.length === 0) { + return

{t('stats.noArchiveData')}

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

{record.label}

+
+ {record.value} + {record.detail && ( + {record.detail} + )} +
+
+
+ ))} +
+ ); +} + export function StatsPage() { const { t } = useTranslation(); const { showToast } = useToast(); @@ -527,12 +929,35 @@ export function StatsPage() { const [dashboardKey, setDashboardKey] = useState(0); const [hiddenCount, setHiddenCount] = useState(0); const [isRecalculating, setIsRecalculating] = useState(false); + const [timeframe, setTimeframe] = useState(() => { + try { + const saved = localStorage.getItem('bambusy-stats-timeframe'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.preset) return parsed; + } + } catch { /* ignore */ } + return { preset: 'all-time', dateFrom: undefined, dateTo: undefined }; + }); + const [showTimeframePicker, setShowTimeframePicker] = useState(false); + + // Persist timeframe selection + useEffect(() => { + localStorage.setItem('bambusy-stats-timeframe', JSON.stringify(timeframe)); + }, [timeframe]); + + const effectiveDateRange = useMemo(() => { + if (timeframe.preset === 'custom') { + return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo }; + } + 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); @@ -545,7 +970,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); @@ -553,8 +978,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({ @@ -562,9 +990,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: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo], + queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo), }); const { data: settings } = useQuery({ @@ -595,7 +1023,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'); @@ -606,7 +1034,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 ( @@ -623,54 +1051,53 @@ export function StatsPage() { { id: 'quick-stats', title: t('stats.quickStats'), - component: , + component: , defaultSize: 2, }, { id: 'success-rate', title: t('stats.successRate'), - component: (size) => , + component: (size) => , defaultSize: 1, }, { id: 'time-accuracy', title: t('stats.timeAccuracy'), - component: (size) => , - defaultSize: 1, - }, - { - id: 'filament-types', - title: t('stats.filamentTypes'), - component: (size) => , + component: (size) => , defaultSize: 1, }, { id: 'failure-analysis', title: t('stats.failureAnalysis'), - component: (size) => , + component: (size) => , defaultSize: 1, }, { id: 'print-activity', title: t('stats.printActivity'), - component: (size) => , + component: (size) => , defaultSize: 2, }, { - id: 'prints-by-printer', - title: t('stats.printsByPrinter'), - component: , - defaultSize: 2, + id: 'records', + title: t('stats.records'), + component: , + defaultSize: 1, + }, + { + id: 'printer-stats', + title: t('stats.printerStats'), + component: , + defaultSize: 4, }, { id: 'filament-trends', title: t('stats.filamentTrends'), - component: , + component: , defaultSize: 4, }, ]; - return (
@@ -696,7 +1123,7 @@ export function StatsPage() {
)}
+ {/* Timeframe Selector */} +
+ + + {showTimeframePicker && ( + <> +
setShowTimeframePicker(false)} + /> +
+ {TIMEFRAME_PRESETS.map((preset) => ( + + ))} + +
+ + + + {timeframe.preset === 'custom' && ( +
+
+ + setTimeframe(prev => ({ ...prev, dateFrom: e.target.value || undefined }))} + className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]" + /> +
+
+ + setTimeframe(prev => ({ ...prev, dateTo: e.target.value || undefined }))} + className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]" + /> +
+ +
+ )} +
+ + )} +
diff --git a/frontend/src/utils/weight.ts b/frontend/src/utils/weight.ts new file mode 100644 index 000000000..f424ed500 --- /dev/null +++ b/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`; +}