From b2603c8c2d968b0a6577bd920c092758733f97b3 Mon Sep 17 00:00:00 2001 From: AneoPsy Date: Sat, 28 Feb 2026 00:54:56 -1000 Subject: [PATCH 1/7] fix(stats): calculate success rate from completed and failed prints only Previously divided successful prints by total_prints which includes cancelled/stopped prints, causing an incorrect percentage. --- frontend/src/pages/StatsPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 9a88c0fe8..018e8e7e5 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -123,8 +123,9 @@ function SuccessRateWidget({ size?: 1 | 2 | 4; t: (key: string) => string; }) { - const successRate = stats?.total_prints - ? Math.round((stats.successful_prints / stats.total_prints) * 100) + 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 From 792a86f1e5474ae1f447311995b3253b817cecf5 Mon Sep 17 00:00:00 2001 From: AneoPsy Date: Sat, 28 Feb 2026 03:12:44 -1000 Subject: [PATCH 2/7] feat(stats): add new widgets, shared MetricToggle, and timeframe filtering Add Print Time of Day, Color Distribution, and Records widgets to the Stats page. Extract shared MetricToggle component for consistent weight/prints/time toggles. Add date range filtering to archives and stats API endpoints. Reorganize widgets into parent cards (Printer Stats and Filament Trends) and fix Dashboard layout persistence for new widgets. --- backend/app/api/routes/archives.py | 54 +- backend/app/services/archive.py | 12 +- backend/app/services/failure_analysis.py | 25 +- frontend/src/api/client.ts | 16 +- frontend/src/components/Dashboard.tsx | 8 + frontend/src/components/FilamentTrends.tsx | 378 +++++++---- frontend/src/components/MetricToggle.tsx | 36 ++ frontend/src/i18n/locales/de.ts | 42 ++ frontend/src/i18n/locales/en.ts | 42 ++ frontend/src/i18n/locales/fr.ts | 42 ++ frontend/src/i18n/locales/it.ts | 42 ++ frontend/src/i18n/locales/ja.ts | 42 ++ frontend/src/i18n/locales/pt-BR.ts | 42 ++ frontend/src/pages/StatsPage.tsx | 707 +++++++++++++++++---- frontend/src/utils/weight.ts | 11 + 15 files changed, 1201 insertions(+), 298 deletions(-) create mode 100644 frontend/src/components/MetricToggle.tsx create mode 100644 frontend/src/utils/weight.ts diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index c783fbf41..09998a206 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 @@ -122,6 +123,8 @@ def archive_to_response( async def list_archives( printer_id: int | None = None, project_id: int | None = None, + date_from: date | None = Query(None), + date_to: date | None = Query(None), limit: int = 50, offset: int = 0, db: AsyncSession = Depends(get_db), @@ -132,6 +135,8 @@ async def list_archives( archives = await service.list_archives( printer_id=printer_id, project_id=project_id, + date_from=date_from, + date_to=date_to, limit=limit, offset=offset, ) @@ -277,7 +282,9 @@ async def rebuild_search_index( @router.get("/analysis/failures") async def analyze_failures( - days: int = 30, + days: int | None = None, + date_from: date | None = Query(None), + date_to: date | None = Query(None), printer_id: int | None = None, project_id: int | None = None, db: AsyncSession = Depends(get_db), @@ -297,6 +304,8 @@ async def analyze_failures( service = FailureAnalysisService(db) return await service.analyze_failures( days=days, + date_from=date_from, + date_to=date_to, printer_id=printer_id, project_id=project_id, ) @@ -440,25 +449,42 @@ async def export_stats( @router.get("/stats", response_model=ArchiveStats) async def get_archive_stats( + date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"), + date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"), db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ), ): """Get statistics across all archives.""" + # Build date filter conditions + base_conditions = [] + if date_from: + dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc) + base_conditions.append(PrintArchive.created_at >= dt_from) + if date_to: + dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc) + base_conditions.append(PrintArchive.created_at <= dt_to) + # Total counts - total_result = await db.execute(select(func.count(PrintArchive.id))) + total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions)) total_prints = total_result.scalar() or 0 - successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")) + successful_result = await db.execute( + select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions) + ) successful_prints = successful_result.scalar() or 0 - failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")) + failed_result = await db.execute( + select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions) + ) failed_prints = failed_result.scalar() or 0 # Totals - use actual print time from timestamps (not slicer estimates) # For archives with both started_at and completed_at, calculate actual duration # Fall back to print_time_seconds only for archives without timestamps archives_for_time = await db.execute( - select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds) + select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where( + *base_conditions + ) ) total_seconds = 0 for started_at, completed_at, print_time_seconds in archives_for_time.all(): @@ -473,15 +499,17 @@ async def get_archive_stats( total_time = total_seconds / 3600 # Convert to hours # Sum filament directly - filament_used_grams already contains the total for the print job - filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0))) + filament_result = await db.execute( + select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions) + ) total_filament = filament_result.scalar() or 0 - cost_result = await db.execute(select(func.sum(PrintArchive.cost))) + cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions)) total_cost = cost_result.scalar() or 0 # By filament type (split comma-separated values for multi-material prints) filament_type_result = await db.execute( - select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None)) + select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions) ) prints_by_filament: dict[str, int] = {} for (filament_types,) in filament_type_result.all(): @@ -493,7 +521,9 @@ async def get_archive_stats( # By printer printer_result = await db.execute( - select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id) + select(PrintArchive.printer_id, func.count(PrintArchive.id)) + .where(*base_conditions) + .group_by(PrintArchive.printer_id) ) prints_by_printer = {str(k): v for k, v in printer_result.all()} @@ -501,7 +531,7 @@ async def get_archive_stats( # Get all completed archives with both estimated and actual times accuracy_result = await db.execute( select(PrintArchive) - .where(PrintArchive.status == "completed") + .where(PrintArchive.status == "completed", *base_conditions) .where(PrintArchive.print_time_seconds.isnot(None)) .where(PrintArchive.started_at.isnot(None)) .where(PrintArchive.completed_at.isnot(None)) @@ -575,10 +605,10 @@ async def get_archive_stats( total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3) else: # Print mode: sum up per-print energy from archives - energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh))) + energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions)) total_energy_kwh = energy_kwh_result.scalar() or 0 - energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost))) + energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions)) total_energy_cost = energy_cost_result.scalar() or 0 return ArchiveStats( 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..36c05731d 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,24 +16,37 @@ class FailureAnalysisService: async def analyze_failures( self, - days: int = 30, + days: int | None = None, + date_from: date | None = None, + date_to: date | None = None, printer_id: int | None = None, project_id: int | None = None, ) -> dict: """Analyze failure patterns across archives. Args: - days: Number of days to analyze + days: Number of days to analyze (fallback when no date range) + date_from: Start date filter (inclusive) + date_to: End date filter (inclusive) printer_id: Optional filter by printer project_id: Optional filter by project Returns: Dictionary with failure analysis results """ - cutoff_date = datetime.now(timezone.utc) - timedelta(days=days) - # Build base query - base_filter = [PrintArchive.created_at >= cutoff_date] + base_filter = [] + if date_from or date_to: + if date_from: + dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc) + base_filter.append(PrintArchive.created_at >= dt_from) + if date_to: + dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc) + base_filter.append(PrintArchive.created_at <= dt_to) + else: + effective_days = days if days is not None else 30 + cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days) + base_filter.append(PrintArchive.created_at >= cutoff_date) if printer_id: base_filter.append(PrintArchive.printer_id == printer_id) if project_id: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3d26ec387..36e431e43 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2489,12 +2489,14 @@ export const api = { request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`), // Archives - getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => { + getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0, dateFrom?: string, dateTo?: string) => { const params = new URLSearchParams(); if (printerId) params.set('printer_id', String(printerId)); if (projectId) params.set('project_id', String(projectId)); params.set('limit', String(limit)); params.set('offset', String(offset)); + if (dateFrom) params.set('date_from', dateFrom); + if (dateTo) params.set('date_to', dateTo); return request(`/archives/?${params}`); }, getArchive: (id: number) => request(`/archives/${id}`), @@ -2536,7 +2538,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,9 +2558,11 @@ export const api = { }), recalculateCosts: () => request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }), - getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => { + getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number }) => { const params = new URLSearchParams(); if (options?.days) params.set('days', String(options.days)); + if (options?.dateFrom) params.set('date_from', options.dateFrom); + if (options?.dateTo) params.set('date_to', options.dateTo); if (options?.printerId) params.set('printer_id', String(options.printerId)); if (options?.projectId) params.set('project_id', String(options.projectId)); return request(`/archives/analysis/failures?${params}`); 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..605c3857c 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,32 @@ import { CartesianGrid, Tooltip, ResponsiveContainer, - BarChart, - Bar, PieChart, Pie, Cell, - Legend, } from 'recharts'; import type { Archive } from '../api/client'; +import { MetricToggle, type Metric } from './MetricToggle'; import { parseUTCDate } from '../utils/date'; +import { formatWeight } from '../utils/weight'; interface FilamentTrendsProps { archives: Archive[]; currency?: string; } -type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all'; - const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316']; -function getDateRange(range: TimeRange): Date { - const now = new Date(); - switch (range) { - case '7d': - return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - case '30d': - return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - case '90d': - return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); - case '365d': - return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); - case 'all': - return new Date(0); - } -} - export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) { - const [timeRange, setTimeRange] = useState('30d'); - - // Filter archives by time range - const filteredArchives = useMemo(() => { - const startDate = getDateRange(timeRange); - return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate); - }, [archives, timeRange]); + const { t } = useTranslation(); + const [filamentTypeMetric, setFilamentTypeMetric] = useState('weight'); + const [colorMetric, setColorMetric] = useState<'weight' | 'prints'>('prints'); // 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 +51,24 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps ...d, dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), })); - }, [filteredArchives]); + }, [archives]); - // Calculate weekly aggregated data for longer time ranges + // Calculate weekly aggregated data when there are many daily points const weeklyData = useMemo(() => { - if (timeRange === '7d' || timeRange === '30d') return dailyData; + if (dailyData.length <= 60) return dailyData; const dataMap = new Map(); - filteredArchives.forEach(archive => { - const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date(); - // Get week start (Sunday) + dailyData.forEach(day => { + const date = new Date(day.date); const weekStart = new Date(date); weekStart.setDate(date.getDate() - date.getDay()); const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`; const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 }; - existing.filament += archive.filament_used_grams || 0; - existing.cost += archive.cost || 0; - existing.prints += archive.quantity || 1; + existing.filament += day.filament; + existing.cost += day.cost; + existing.prints += day.prints; dataMap.set(key, existing); }); @@ -102,13 +79,13 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, ...d, })); - }, [filteredArchives, dailyData, timeRange]); + }, [dailyData]); // Usage by filament type const filamentTypeData = useMemo(() => { const dataMap = new Map(); - filteredArchives.forEach(archive => { + archives.forEach(archive => { const type = archive.filament_type || 'Unknown'; // Handle multiple types (e.g., "PLA, PETG") const types = type.split(', '); @@ -121,80 +98,119 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps return Array.from(dataMap.entries()) .map(([name, value]) => ({ name, value: Math.round(value) })) .sort((a, b) => b.value - a.value); - }, [filteredArchives]); - - // 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 = 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 +226,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps {/* Usage Over Time Chart */} {chartData.length > 0 ? (

-

Usage Over Time

+

{t('stats.usageOverTime')}

@@ -253,21 +269,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 +304,18 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps border: '1px solid #3d3d3d', borderRadius: '8px', }} - formatter={(value) => [`${value ?? 0}g`, 'Usage']} + formatter={(value) => [ + filamentTypeMetric === 'weight' ? formatWeight(Number(value ?? 0)) : + filamentTypeMetric === 'time' ? `${Number(value ?? 0)}h` : + `${value ?? 0}`, + filamentTypeMetric === 'weight' ? 'Usage' : filamentTypeMetric === 'time' ? 'Time' : 'Prints', + ]} />
- {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 +324,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 +336,114 @@ 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'), + ]} + /> + + +
+ {colorData.slice(0, 8).map((entry) => { + const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0; + return ( +
+
+ + {colorMetric === 'weight' ? formatWeight(entry.value) : entry.value} · {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..5e6726b23 --- /dev/null +++ b/frontend/src/components/MetricToggle.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; + +export type Metric = 'weight' | 'prints' | 'time'; + +const METRICS: Metric[] = ['weight', 'prints', 'time']; + +interface MetricToggleProps { + value: Metric; + onChange: (metric: Metric) => void; +} + +export function MetricToggle({ value, onChange }: MetricToggleProps) { + const { t } = useTranslation(); + + const labels: Record = { + weight: t('stats.filamentByWeight'), + prints: t('stats.filamentByPrints'), + time: t('stats.filamentByTime'), + }; + + 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 018e8e7e5..c3a0f82e9 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 Archive } from '../api/client'; import { PrintCalendar } from '../components/PrintCalendar'; import { FilamentTrends } from '../components/FilamentTrends'; import { Dashboard, type DashboardWidget } from '../components/Dashboard'; import { getCurrencySymbol } from '../utils/currency'; +import { formatWeight } from '../utils/weight'; +import { parseUTCDate, formatDuration } from '../utils/date'; +import { MetricToggle, type Metric } from '../components/MetricToggle'; + +// Timeframe types and helpers +type TimeframePreset = 'today' | 'this-week' | 'this-month' | 'last-7' | 'last-30' | 'last-90' | 'this-year' | 'all-time' | 'custom'; + +interface TimeframeState { + preset: TimeframePreset; + dateFrom: string | undefined; // YYYY-MM-DD + dateTo: string | undefined; // YYYY-MM-DD +} + +function computeDateRange(preset: TimeframePreset): { dateFrom?: string; dateTo?: string } { + const now = new Date(); + const y = now.getUTCFullYear(), m = now.getUTCMonth(), d = now.getUTCDate(); + const fmt = (dt: Date) => dt.toISOString().split('T')[0]; + const todayStr = fmt(now); + + switch (preset) { + case 'today': + return { dateFrom: todayStr, dateTo: todayStr }; + case 'this-week': { + const day = now.getUTCDay(); + const start = new Date(Date.UTC(y, m, d - (day === 0 ? 6 : day - 1))); + return { dateFrom: fmt(start), dateTo: todayStr }; + } + case 'this-month': + return { dateFrom: fmt(new Date(Date.UTC(y, m, 1))), dateTo: todayStr }; + case 'last-7': + return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 6))), dateTo: todayStr }; + case 'last-30': + return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 29))), dateTo: todayStr }; + case 'last-90': + return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 89))), dateTo: todayStr }; + case 'this-year': + return { dateFrom: fmt(new Date(Date.UTC(y, 0, 1))), dateTo: todayStr }; + case 'all-time': + return { dateFrom: undefined, dateTo: undefined }; + case 'custom': + return {}; + } +} + +const TIMEFRAME_PRESETS: TimeframePreset[] = [ + 'today', 'this-week', 'this-month', + 'last-7', 'last-30', 'last-90', + 'this-year', 'all-time', +]; + +// Constants +const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +const HOUR_LABELS = [ + '12am', '1am', '2am', '3am', '4am', '5am', + '6am', '7am', '8am', '9am', '10am', '11am', + '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', + '6pm', '7pm', '8pm', '9pm', '10pm', '11pm', +]; + +const DURATION_BUCKETS = [ + { key: '<30m', max: 1800 }, + { key: '30m-1h', max: 3600 }, + { key: '1-2h', max: 7200 }, + { key: '2-4h', max: 14400 }, + { key: '4-8h', max: 28800 }, + { key: '8-12h', max: 43200 }, + { key: '12-24h', max: 86400 }, + { key: '24h+', max: Infinity }, +]; + +const RECHARTS_TOOLTIP_STYLE = { + backgroundColor: '#2d2d2d', + border: '1px solid #3d3d3d', + borderRadius: '8px', +}; // Widget Components function QuickStatsWidget({ stats, currency, - t, }: { stats: { total_prints: number; @@ -45,8 +130,8 @@ function QuickStatsWidget({ total_energy_cost: number; } | undefined; currency: string; - t: (key: string) => string; }) { + const { t } = useTranslation(); return (
@@ -73,7 +158,7 @@ function QuickStatsWidget({

{t('stats.filamentUsed')}

-

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

+

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

@@ -111,7 +196,6 @@ function SuccessRateWidget({ stats, printerMap, size = 1, - t, }: { stats: { total_prints: number; @@ -121,8 +205,8 @@ function SuccessRateWidget({ } | undefined; printerMap: Map; size?: 1 | 2 | 4; - t: (key: string) => string; }) { + const { t } = useTranslation(); const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0); const successRate = completedAndFailed ? Math.round((stats!.successful_prints / completedAndFailed) * 100) @@ -198,7 +282,6 @@ function TimeAccuracyWidget({ stats, printerMap, size = 1, - t, }: { stats: { average_time_accuracy: number | null; @@ -206,8 +289,8 @@ function TimeAccuracyWidget({ } | undefined; printerMap: Map; size?: 1 | 2 | 4; - t: (key: string) => string; }) { + const { t } = useTranslation(); const accuracy = stats?.average_time_accuracy; if (accuracy === null || accuracy === undefined) { @@ -300,85 +383,6 @@ function TimeAccuracyWidget({ ); } -function FilamentTypesWidget({ - stats, - size = 1, - t, -}: { - stats: { - total_prints: number; - prints_by_filament_type: Record; - } | 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')}

; - } - - // Sort by print count descending - const sortedEntries = Object.entries(stats.prints_by_filament_type).sort( - ([, a], [, b]) => b - a - ); - - // Limit entries based on size - const maxEntries = size === 1 ? 5 : size === 2 ? 8 : 999; - const displayEntries = sortedEntries.slice(0, maxEntries); - const hasMore = sortedEntries.length > maxEntries; - - // Use grid layout when expanded - if (size === 4 && displayEntries.length > 4) { - return ( -
- {displayEntries.map(([type, count]) => { - const percentage = Math.round((count / (stats.total_prints || 1)) * 100); - return ( -
-
- {type} - {count} -
-
-
-
-
- ); - })} -
- ); - } - - return ( -
- {displayEntries.map(([type, count]) => { - const percentage = Math.round((count / (stats.total_prints || 1)) * 100); - return ( -
-
- {type} - {count} {t('common.prints')} -
-
-
-
-
- ); - })} - {hasMore && ( -

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

- )} -
- ); -} - function PrintActivityWidget({ printDates, size = 2, @@ -391,34 +395,205 @@ function PrintActivityWidget({ return ; } -function PrintsByPrinterWidget({ +function PrinterStatsWidget({ stats, + archives, printerMap, - t, }: { stats: { prints_by_printer: Record } | undefined; + archives: Archive[]; 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.toISOString().split('T')[0]); + }); + const numWeeks = Math.max(weeksSet.size, 1); + return DAY_LABELS.map((name, i) => ({ + name, + avg: Math.round((dayValues[i] / numWeeks) * 10) / 10, + })); + }, [archives, habitsMetric]); + + const pUnit = printerMetric === 'weight' ? 'g' : printerMetric === 'time' ? 'h' : ''; + const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints'); + const pColor = printerMetric === 'weight' ? '#00ae42' : printerMetric === 'time' ? '#3b82f6' : '#f59e0b'; + + const hUnit = habitsMetric === 'weight' ? 'g' : habitsMetric === 'time' ? 'h' : ''; + const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints'); + const hColor = habitsMetric === 'weight' ? '#00ae42' : habitsMetric === 'time' ? '#3b82f6' : '#f59e0b'; return ( -
- {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}${pUnit}`, + pLabel, + ]} + /> + + + + ) : ( +

{t('stats.noPrinterData')}

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

{t('stats.printDuration')}

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

{t('stats.noArchiveData')}

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

{t('stats.printHabits')}

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

{t('stats.noArchiveData')}

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

{t('stats.printTimeOfDay')}

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

{t('stats.noArchiveData')}

+ )} +
+
); } @@ -426,22 +601,29 @@ function PrintsByPrinterWidget({ function FilamentTrendsWidget({ archives, currency, - t, }: { archives: Parameters[0]['archives']; currency: string; - t: (key: string) => string; }) { + const { t } = useTranslation(); if (!archives || archives.length === 0) { return

{t('stats.noPrintData')}

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

{t('stats.noArchiveData')}

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

{record.label}

+
+ {record.value} + {record.detail && ( + {record.detail} + )} +
+
+
+ ))} +
+ ); +} + export function StatsPage() { const { t } = useTranslation(); const { showToast } = useToast(); @@ -528,12 +854,25 @@ export function StatsPage() { const [dashboardKey, setDashboardKey] = useState(0); const [hiddenCount, setHiddenCount] = useState(0); const [isRecalculating, setIsRecalculating] = useState(false); + const [timeframe, setTimeframe] = useState({ + preset: 'all-time', + dateFrom: undefined, + dateTo: undefined, + }); + const [showTimeframePicker, setShowTimeframePicker] = useState(false); + + const effectiveDateRange = useMemo(() => { + if (timeframe.preset === 'custom') { + return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo }; + } + return computeDateRange(timeframe.preset); + }, [timeframe]); // Read hidden count from localStorage useEffect(() => { const updateHiddenCount = () => { try { - const saved = localStorage.getItem('bambusy-dashboard-layout'); + const saved = localStorage.getItem('bambusy-dashboard-layout-v2'); if (saved) { const layout = JSON.parse(saved); setHiddenCount(layout.hidden?.length || 0); @@ -546,7 +885,7 @@ export function StatsPage() { // Listen for storage changes window.addEventListener('storage', updateHiddenCount); // Also poll for changes (since storage event doesn't fire for same-tab changes) - const interval = setInterval(updateHiddenCount, 500); + const interval = setInterval(updateHiddenCount, 2000); return () => { window.removeEventListener('storage', updateHiddenCount); clearInterval(interval); @@ -554,8 +893,11 @@ export function StatsPage() { }, [dashboardKey]); const { data: stats, isLoading, refetch: refetchStats } = useQuery({ - queryKey: ['archiveStats'], - queryFn: api.getArchiveStats, + queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo], + queryFn: () => api.getArchiveStats({ + dateFrom: effectiveDateRange.dateFrom, + dateTo: effectiveDateRange.dateTo, + }), }); const { data: printers } = useQuery({ @@ -563,9 +905,9 @@ export function StatsPage() { queryFn: api.getPrinters, }); - const { data: archives } = useQuery({ - queryKey: ['archives'], - queryFn: () => api.getArchives(undefined, undefined, 1000, 0), + const { data: archives, refetch: refetchArchives } = useQuery({ + queryKey: ['archives', effectiveDateRange.dateFrom, effectiveDateRange.dateTo], + queryFn: () => api.getArchives(undefined, undefined, 10000, 0, effectiveDateRange.dateFrom, effectiveDateRange.dateTo), }); const { data: settings } = useQuery({ @@ -596,7 +938,7 @@ export function StatsPage() { setIsRecalculating(true); try { const result = await api.recalculateCosts(); - await refetchStats(); + await Promise.all([refetchStats(), refetchArchives()]); showToast(t('stats.recalculatedCosts', { count: result.updated })); } catch { showToast(t('stats.recalculateFailed'), 'error'); @@ -607,7 +949,7 @@ export function StatsPage() { const currency = getCurrencySymbol(settings?.currency || 'USD'); const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []); - const printDates = archives?.map((a) => a.created_at) || []; + const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]); if (isLoading) { return ( @@ -624,31 +966,25 @@ export function StatsPage() { { id: 'quick-stats', title: t('stats.quickStats'), - component: , + 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, }, { @@ -658,20 +994,25 @@ export function StatsPage() { 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 (
@@ -697,7 +1038,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`; +} From 1a7bdf2b187aef54bb1d1099276bc6968bb747bb Mon Sep 17 00:00:00 2001 From: AneoPsy Date: Sat, 28 Feb 2026 03:20:26 -1000 Subject: [PATCH 3/7] test(stats): update StatsPage tests to match current widget structure Enrich mock archives with full fields, fix failure analysis endpoint, remove outdated Filament Types test, and add coverage for new widgets (Records, Printer Stats sub-cards, Filament Trends sub-cards). --- .../src/__tests__/pages/StatsPage.test.tsx | 216 ++++++++++++++++-- 1 file changed, 203 insertions(+), 13 deletions(-) diff --git a/frontend/src/__tests__/pages/StatsPage.test.tsx b/frontend/src/__tests__/pages/StatsPage.test.tsx index fdeaf501e..cbf179aaa 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,6 +122,16 @@ 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 }, @@ -81,7 +153,7 @@ describe('StatsPage', () => { 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', () => { From 5216bebca7a7f5dbe2ec5506f11bbf2f36818e2b Mon Sep 17 00:00:00 2001 From: AneoPsy Date: Sat, 28 Feb 2026 19:45:50 -1000 Subject: [PATCH 4/7] refactor(stats): improve Color Distribution card layout and toggle Use MetricToggle component with new exclude prop instead of inline buttons, default to weight-first order, and redesign chart layout with centered donut, total label, and 2-column legend grid. --- frontend/src/components/FilamentTrends.tsx | 99 ++++++------ frontend/src/components/MetricToggle.tsx | 7 +- frontend/src/pages/StatsPage.tsx | 167 +++++++-------------- 3 files changed, 113 insertions(+), 160 deletions(-) diff --git a/frontend/src/components/FilamentTrends.tsx b/frontend/src/components/FilamentTrends.tsx index 605c3857c..226e1e022 100644 --- a/frontend/src/components/FilamentTrends.tsx +++ b/frontend/src/components/FilamentTrends.tsx @@ -27,7 +27,7 @@ const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899' export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) { const { t } = useTranslation(); const [filamentTypeMetric, setFilamentTypeMetric] = useState('weight'); - const [colorMetric, setColorMetric] = useState<'weight' | 'prints'>('prints'); + const [colorMetric, setColorMetric] = useState('weight'); // Calculate daily usage data const dailyData = useMemo(() => { @@ -377,70 +377,71 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps

{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'), - ]} - /> - - -
+
+
+ + + + {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 ( -
-
+
- - {colorMetric === 'weight' ? formatWeight(entry.value) : entry.value} · {percent}% + + {percent}%
); })} - {colorData.length > 8 && ( -

+{colorData.length - 8} more

- )}
+ {colorData.length > 8 && ( +

+{colorData.length - 8} more

+ )}
); })() : ( -
+
{t('stats.noColorData')}
)} diff --git a/frontend/src/components/MetricToggle.tsx b/frontend/src/components/MetricToggle.tsx index 5e6726b23..e508969a3 100644 --- a/frontend/src/components/MetricToggle.tsx +++ b/frontend/src/components/MetricToggle.tsx @@ -7,9 +7,10 @@ const METRICS: Metric[] = ['weight', 'prints', 'time']; interface MetricToggleProps { value: Metric; onChange: (metric: Metric) => void; + exclude?: Metric[]; } -export function MetricToggle({ value, onChange }: MetricToggleProps) { +export function MetricToggle({ value, onChange, exclude }: MetricToggleProps) { const { t } = useTranslation(); const labels: Record = { @@ -18,9 +19,11 @@ export function MetricToggle({ value, onChange }: MetricToggleProps) { time: t('stats.filamentByTime'), }; + const metrics = exclude ? METRICS.filter(m => !exclude.includes(m)) : METRICS; + return (
- {METRICS.map(m => ( + {metrics.map(m => (