Browse Source

Added calculation for print time accuracy; Added duplicate detection and filter

Martin Ziegler 6 months ago
parent
commit
b407e1b6d8

+ 12 - 0
README.md

@@ -35,6 +35,16 @@
   - Filament usage trends
   - Print activity calendar
   - Cost tracking
+  - Time accuracy tracking
+- **Print Time Accuracy** - Compare estimated vs actual print times
+  - Color-coded badges (green=accurate, blue=faster, orange=slower)
+  - Per-printer accuracy statistics
+  - Dashboard widget showing average accuracy
+- **Duplicate Detection** - Automatically detect when models have been printed before
+  - SHA256 content hash for exact matching
+  - Purple badge indicator on archive cards
+  - "Duplicates" collection filter to find all duplicates
+  - View duplicate history when viewing archive details
 - **Filament Cost Tracking** - Track costs per print with customizable filament database
 - **Photo Attachments** - Attach photos to archived prints for documentation
 - **Failure Analysis** - Document failed prints with notes and photos
@@ -488,6 +498,8 @@ sudo journalctl -u bambusy -f
 - [ ] Mobile-responsive improvements
 - [ ] Printer groups/organization
 - [x] Smart plug integration (Tasmota)
+- [x] Print time accuracy tracking
+- [x] Duplicate detection
 
 ## License
 

+ 176 - 2
backend/app/api/routes/archives.py

@@ -17,6 +17,78 @@ from backend.app.services.archive import ArchiveService
 router = APIRouter(prefix="/archives", tags=["archives"])
 
 
+def compute_time_accuracy(archive: PrintArchive) -> dict:
+    """Compute actual print time and accuracy for an archive.
+
+    Returns dict with actual_time_seconds and time_accuracy.
+    time_accuracy = (estimated / actual) * 100
+    - 100% = perfect estimate
+    - >100% = print was faster than estimated
+    - <100% = print took longer than estimated
+    """
+    result = {"actual_time_seconds": None, "time_accuracy": None}
+
+    if archive.started_at and archive.completed_at and archive.status == "completed":
+        actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
+        if actual_seconds > 0:
+            result["actual_time_seconds"] = actual_seconds
+
+            if archive.print_time_seconds and archive.print_time_seconds > 0:
+                # Calculate accuracy as percentage
+                accuracy = (archive.print_time_seconds / actual_seconds) * 100
+                result["time_accuracy"] = round(accuracy, 1)
+
+    return result
+
+
+def archive_to_response(
+    archive: PrintArchive,
+    duplicates: list[dict] | None = None,
+    duplicate_count: int = 0,
+) -> dict:
+    """Convert archive model to response dict with computed fields."""
+    data = {
+        "id": archive.id,
+        "printer_id": archive.printer_id,
+        "filename": archive.filename,
+        "file_path": archive.file_path,
+        "file_size": archive.file_size,
+        "content_hash": archive.content_hash,
+        "thumbnail_path": archive.thumbnail_path,
+        "timelapse_path": archive.timelapse_path,
+        "duplicates": duplicates,
+        "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
+        "print_name": archive.print_name,
+        "print_time_seconds": archive.print_time_seconds,
+        "filament_used_grams": archive.filament_used_grams,
+        "filament_type": archive.filament_type,
+        "filament_color": archive.filament_color,
+        "layer_height": archive.layer_height,
+        "nozzle_diameter": archive.nozzle_diameter,
+        "bed_temperature": archive.bed_temperature,
+        "nozzle_temperature": archive.nozzle_temperature,
+        "status": archive.status,
+        "started_at": archive.started_at,
+        "completed_at": archive.completed_at,
+        "extra_data": archive.extra_data,
+        "makerworld_url": archive.makerworld_url,
+        "designer": archive.designer,
+        "is_favorite": archive.is_favorite,
+        "tags": archive.tags,
+        "notes": archive.notes,
+        "cost": archive.cost,
+        "photos": archive.photos,
+        "failure_reason": archive.failure_reason,
+        "created_at": archive.created_at,
+    }
+
+    # Add computed time accuracy fields
+    accuracy_data = compute_time_accuracy(archive)
+    data.update(accuracy_data)
+
+    return data
+
+
 @router.get("/", response_model=list[ArchiveResponse])
 async def list_archives(
     printer_id: int | None = None,
@@ -26,12 +98,22 @@ async def list_archives(
 ):
     """List archived prints."""
     service = ArchiveService(db)
-    return await service.list_archives(
+    archives = await service.list_archives(
         printer_id=printer_id,
         limit=limit,
         offset=offset,
     )
 
+    # Get set of hashes that have duplicates (efficient single query)
+    duplicate_hashes = await service.get_duplicate_hashes()
+
+    # Mark archives that have duplicates
+    result = []
+    for a in archives:
+        has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False
+        result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
+    return result
+
 
 @router.get("/stats", response_model=ArchiveStats)
 async def get_archive_stats(db: AsyncSession = Depends(get_db)):
@@ -86,6 +168,42 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
+    # Time accuracy statistics
+    # Get all completed archives with both estimated and actual times
+    accuracy_result = await db.execute(
+        select(PrintArchive)
+        .where(PrintArchive.status == "completed")
+        .where(PrintArchive.print_time_seconds.isnot(None))
+        .where(PrintArchive.started_at.isnot(None))
+        .where(PrintArchive.completed_at.isnot(None))
+    )
+    archives_with_times = list(accuracy_result.scalars().all())
+
+    average_accuracy = None
+    accuracy_by_printer: dict[str, float] = {}
+
+    if archives_with_times:
+        accuracies = []
+        printer_accuracies: dict[str, list[float]] = {}
+
+        for archive in archives_with_times:
+            acc_data = compute_time_accuracy(archive)
+            if acc_data["time_accuracy"] is not None:
+                accuracies.append(acc_data["time_accuracy"])
+
+                # Group by printer
+                printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
+                if printer_key not in printer_accuracies:
+                    printer_accuracies[printer_key] = []
+                printer_accuracies[printer_key].append(acc_data["time_accuracy"])
+
+        if accuracies:
+            average_accuracy = round(sum(accuracies) / len(accuracies), 1)
+
+        # Calculate per-printer averages
+        for printer_key, accs in printer_accuracies.items():
+            accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
+
     return ArchiveStats(
         total_prints=total_prints,
         successful_prints=successful_prints,
@@ -95,6 +213,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
         total_cost=round(total_cost, 2),
         prints_by_filament_type=prints_by_filament,
         prints_by_printer=prints_by_printer,
+        average_time_accuracy=average_accuracy,
+        time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
     )
 
 
@@ -105,7 +225,16 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     archive = await service.get_archive(archive_id)
     if not archive:
         raise HTTPException(404, "Archive not found")
-    return archive
+
+    # Find duplicates
+    makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
+    duplicates = await service.find_duplicates(
+        archive_id=archive.id,
+        content_hash=archive.content_hash,
+        print_name=archive.print_name,
+        makerworld_model_id=makerworld_id,
+    )
+    return archive_to_response(archive, duplicates)
 
 
 @router.patch("/{archive_id}", response_model=ArchiveResponse)
@@ -242,6 +371,51 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
     return {"updated": updated, "errors": errors}
 
 
+@router.get("/{archive_id}/duplicates")
+async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get duplicates for a specific archive."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
+    duplicates = await service.find_duplicates(
+        archive_id=archive.id,
+        content_hash=archive.content_hash,
+        print_name=archive.print_name,
+        makerworld_model_id=makerworld_id,
+    )
+    return {"duplicates": duplicates, "count": len(duplicates)}
+
+
+@router.post("/backfill-hashes")
+async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
+    """Compute and store content hashes for all archives missing them."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.content_hash.is_(None))
+    )
+    archives = list(result.scalars().all())
+
+    updated = 0
+    errors = []
+
+    for archive in archives:
+        try:
+            file_path = settings.base_dir / archive.file_path
+            if not file_path.exists():
+                errors.append({"id": archive.id, "error": "File not found"})
+                continue
+
+            archive.content_hash = ArchiveService.compute_file_hash(file_path)
+            updated += 1
+        except Exception as e:
+            errors.append({"id": archive.id, "error": str(e)})
+
+    await db.commit()
+    return {"updated": updated, "errors": errors}
+
+
 @router.delete("/{archive_id}")
 async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     """Delete an archive."""

+ 9 - 0
backend/app/core/database.py

@@ -55,3 +55,12 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add content_hash column to print_archives for duplicate detection
+    try:
+        await conn.execute(text(
+            "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 1 - 0
backend/app/models/archive.py

@@ -15,6 +15,7 @@ class PrintArchive(Base):
     filename: Mapped[str] = mapped_column(String(255))
     file_path: Mapped[str] = mapped_column(String(500))
     file_size: Mapped[int] = mapped_column(Integer)
+    content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
 

+ 19 - 1
backend/app/schemas/archive.py

@@ -15,17 +15,32 @@ class ArchiveUpdate(ArchiveBase):
     printer_id: int | None = None
 
 
+class ArchiveDuplicate(BaseModel):
+    """Reference to a duplicate archive."""
+    id: int
+    print_name: str | None
+    created_at: datetime
+    match_type: str  # "exact" (hash match) or "similar" (name match)
+
+
 class ArchiveResponse(BaseModel):
     id: int
     printer_id: int | None
     filename: str
     file_path: str
     file_size: int
+    content_hash: str | None
     thumbnail_path: str | None
     timelapse_path: str | None
 
+    # Duplicate detection
+    duplicates: list[ArchiveDuplicate] | None = None
+    duplicate_count: int = 0  # Quick count for list views
+
     print_name: str | None
-    print_time_seconds: int | None
+    print_time_seconds: int | None  # Estimated time from slicer
+    actual_time_seconds: int | None = None  # Computed from started_at/completed_at
+    time_accuracy: float | None = None  # Percentage: 100 = perfect, >100 = faster than estimated
     filament_used_grams: float | None
     filament_type: str | None
     filament_color: str | None
@@ -65,6 +80,9 @@ class ArchiveStats(BaseModel):
     total_cost: float
     prints_by_filament_type: dict
     prints_by_printer: dict
+    # Time accuracy stats
+    average_time_accuracy: float | None = None  # Average across all prints with data
+    time_accuracy_by_printer: dict | None = None  # Per-printer accuracy
 
 
 class ProjectPageImage(BaseModel):

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

@@ -1,3 +1,4 @@
+import hashlib
 import json
 import zipfile
 import shutil
@@ -6,7 +7,7 @@ from pathlib import Path
 from xml.etree import ElementTree as ET
 
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, and_, or_
 
 from backend.app.core.config import settings
 from backend.app.models.archive import PrintArchive
@@ -470,6 +471,100 @@ class ArchiveService:
     def __init__(self, db: AsyncSession):
         self.db = db
 
+    @staticmethod
+    def compute_file_hash(file_path: Path) -> str:
+        """Compute SHA256 hash of a file for duplicate detection."""
+        sha256 = hashlib.sha256()
+        with open(file_path, "rb") as f:
+            # Read in chunks to handle large files
+            for chunk in iter(lambda: f.read(8192), b""):
+                sha256.update(chunk)
+        return sha256.hexdigest()
+
+    async def get_duplicate_hashes(self) -> set[str]:
+        """Get all content hashes that appear more than once.
+
+        Returns a set of hashes that have duplicates.
+        """
+        from sqlalchemy import func
+
+        result = await self.db.execute(
+            select(PrintArchive.content_hash)
+            .where(PrintArchive.content_hash.isnot(None))
+            .group_by(PrintArchive.content_hash)
+            .having(func.count(PrintArchive.id) > 1)
+        )
+        return {row[0] for row in result.all()}
+
+    async def find_duplicates(
+        self,
+        archive_id: int,
+        content_hash: str | None = None,
+        print_name: str | None = None,
+        makerworld_model_id: str | None = None,
+    ) -> list[dict]:
+        """Find duplicate archives based on hash or name matching.
+
+        Returns list of dicts with id, print_name, created_at, match_type.
+        """
+        duplicates = []
+
+        # First, find exact matches by content hash
+        if content_hash:
+            result = await self.db.execute(
+                select(PrintArchive)
+                .where(
+                    and_(
+                        PrintArchive.content_hash == content_hash,
+                        PrintArchive.id != archive_id,
+                    )
+                )
+                .order_by(PrintArchive.created_at.desc())
+                .limit(10)
+            )
+            for archive in result.scalars().all():
+                duplicates.append({
+                    "id": archive.id,
+                    "print_name": archive.print_name,
+                    "created_at": archive.created_at,
+                    "match_type": "exact",
+                })
+
+        # Then, find similar matches by print name or MakerWorld ID
+        if print_name or makerworld_model_id:
+            conditions = [PrintArchive.id != archive_id]
+
+            name_conditions = []
+            if print_name:
+                # Match if print names are similar (ignoring case)
+                name_conditions.append(PrintArchive.print_name.ilike(print_name))
+            if makerworld_model_id:
+                # Match by MakerWorld model ID stored in extra_data
+                name_conditions.append(
+                    PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id
+                )
+
+            if name_conditions:
+                conditions.append(or_(*name_conditions))
+
+                result = await self.db.execute(
+                    select(PrintArchive)
+                    .where(and_(*conditions))
+                    .order_by(PrintArchive.created_at.desc())
+                    .limit(10)
+                )
+                for archive in result.scalars().all():
+                    # Don't add if already in duplicates (exact match)
+                    if not any(d["id"] == archive.id for d in duplicates):
+                        duplicates.append({
+                            "id": archive.id,
+                            "print_name": archive.print_name,
+                            "created_at": archive.created_at,
+                            "match_type": "similar",
+                        })
+
+        return duplicates
+
     async def archive_print(
         self,
         printer_id: int | None,
@@ -498,6 +593,9 @@ class ArchiveService:
         dest_file = archive_dir / source_file.name
         shutil.copy2(source_file, dest_file)
 
+        # Compute content hash for duplicate detection
+        content_hash = self.compute_file_hash(dest_file)
+
         # Parse 3MF metadata
         parser = ThreeMFParser(dest_file)
         metadata = parser.parse()
@@ -526,6 +624,7 @@ class ArchiveService:
             filename=source_file.name,
             file_path=str(dest_file.relative_to(settings.base_dir)),
             file_size=dest_file.stat().st_size,
+            content_hash=content_hash,
             thumbnail_path=thumbnail_path,
             print_name=metadata.get("print_name") or source_file.stem,
             print_time_seconds=metadata.get("print_time_seconds"),

+ 20 - 0
frontend/src/api/client.ts

@@ -66,16 +66,28 @@ export interface PrinterCreate {
 }
 
 // Archive types
+export interface ArchiveDuplicate {
+  id: number;
+  print_name: string | null;
+  created_at: string;
+  match_type: 'exact' | 'similar';  // 'exact' = hash match, 'similar' = name match
+}
+
 export interface Archive {
   id: number;
   printer_id: number | null;
   filename: string;
   file_path: string;
   file_size: number;
+  content_hash: string | null;
   thumbnail_path: string | null;
   timelapse_path: string | null;
+  duplicates: ArchiveDuplicate[] | null;
+  duplicate_count: number;
   print_name: string | null;
   print_time_seconds: number | null;
+  actual_time_seconds: number | null;  // Computed from started_at/completed_at
+  time_accuracy: number | null;  // Percentage: 100 = perfect, >100 = faster than estimated
   filament_used_grams: number | null;
   filament_type: string | null;
   filament_color: string | null;
@@ -107,6 +119,8 @@ export interface ArchiveStats {
   total_cost: number;
   prints_by_filament_type: Record<string, number>;
   prints_by_printer: Record<string, number>;
+  average_time_accuracy: number | null;
+  time_accuracy_by_printer: Record<string, number> | null;
 }
 
 export interface BulkUploadResult {
@@ -296,6 +310,12 @@ export const api = {
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  getArchiveDuplicates: (id: number) =>
+    request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
+  backfillContentHashes: () =>
+    request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', {
+      method: 'POST',
+    }),
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,

+ 35 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -293,6 +293,16 @@ function ArchiveCard({
             failed
           </div>
         )}
+        {/* Duplicate badge */}
+        {archive.duplicate_count > 0 && (
+          <div
+            className="absolute top-2 right-2 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
+            title="This model has been printed before"
+          >
+            <Copy className="w-3 h-3" />
+            duplicate
+          </div>
+        )}
         {/* Timelapse badge */}
         {archive.timelapse_path && (
           <button
@@ -335,10 +345,27 @@ function ArchiveCard({
 
         {/* Stats */}
         <div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
-          {archive.print_time_seconds && (
-            <div className="flex items-center gap-1.5 text-bambu-gray">
+          {(archive.print_time_seconds || archive.actual_time_seconds) && (
+            <div className="flex items-center gap-1.5 text-bambu-gray" title={
+              archive.time_accuracy
+                ? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
+                : archive.actual_time_seconds
+                  ? `Actual: ${formatDuration(archive.actual_time_seconds)}`
+                  : `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`
+            }>
               <Clock className="w-3 h-3" />
-              {formatDuration(archive.print_time_seconds)}
+              {formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)}
+              {archive.time_accuracy && (
+                <span className={`text-[10px] px-1 rounded ${
+                  archive.time_accuracy >= 95 && archive.time_accuracy <= 105
+                    ? 'bg-bambu-green/20 text-bambu-green'
+                    : archive.time_accuracy > 105
+                      ? 'bg-blue-500/20 text-blue-400'
+                      : 'bg-orange-500/20 text-orange-400'
+                }`}>
+                  {archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}%
+                </span>
+              )}
             </div>
           )}
           {archive.filament_used_grams && (
@@ -621,7 +648,7 @@ function ArchiveCard({
 
 type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
 type ViewMode = 'grid' | 'list' | 'calendar';
-type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed';
+type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
 
 const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
   { id: 'all', label: 'All Archives', icon: <FolderOpen className="w-4 h-4" /> },
@@ -630,6 +657,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
   { id: 'this-month', label: 'This Month', icon: <Calendar className="w-4 h-4" /> },
   { id: 'favorites', label: 'Favorites', icon: <Star className="w-4 h-4" /> },
   { id: 'failed', label: 'Failed Prints', icon: <AlertCircle className="w-4 h-4" /> },
+  { id: 'duplicates', label: 'Duplicates', icon: <Copy className="w-4 h-4" /> },
 ];
 
 export function ArchivesPage() {
@@ -716,6 +744,9 @@ export function ArchivesPage() {
         case 'failed':
           matchesCollection = a.status === 'failed';
           break;
+        case 'duplicates':
+          matchesCollection = a.duplicate_count > 0;
+          break;
       }
 
       // Search filter

+ 91 - 0
frontend/src/pages/StatsPage.tsx

@@ -6,6 +6,7 @@ import {
   XCircle,
   DollarSign,
   Printer,
+  Target,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
@@ -118,6 +119,90 @@ function SuccessRateWidget({
   );
 }
 
+function TimeAccuracyWidget({
+  stats,
+  printerMap,
+}: {
+  stats: {
+    average_time_accuracy: number | null;
+    time_accuracy_by_printer: Record<string, number> | null;
+  } | undefined;
+  printerMap: Map<string, string>;
+}) {
+  const accuracy = stats?.average_time_accuracy;
+
+  if (accuracy === null || accuracy === undefined) {
+    return (
+      <div className="flex items-center justify-center h-full">
+        <p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
+      </div>
+    );
+  }
+
+  // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)
+  const displayValue = Math.min(150, Math.max(50, accuracy));
+  const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100
+
+  // Color based on accuracy
+  const getColor = (acc: number) => {
+    if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%
+    if (acc > 105) return '#3b82f6'; // Blue - faster than expected
+    return '#f97316'; // Orange - slower than expected
+  };
+
+  const color = getColor(accuracy);
+  const deviation = accuracy - 100;
+
+  return (
+    <div className="flex items-center gap-6">
+      <div className="relative w-28 h-28">
+        <svg className="w-full h-full -rotate-90">
+          <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
+          <circle
+            cx="56"
+            cy="56"
+            r="48"
+            fill="none"
+            stroke={color}
+            strokeWidth="10"
+            strokeLinecap="round"
+            strokeDasharray={`${normalizedForGauge * 3.02} 302`}
+          />
+        </svg>
+        <div className="absolute inset-0 flex flex-col items-center justify-center">
+          <span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
+          <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
+            {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
+          </span>
+        </div>
+      </div>
+      <div className="space-y-2 flex-1">
+        <div className="flex items-center gap-2 text-xs text-bambu-gray">
+          <Target className="w-3 h-3" />
+          <span>100% = perfect estimate</span>
+        </div>
+        {stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
+          <div className="space-y-1 mt-2">
+            {Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
+              <div key={printerId} className="flex items-center justify-between text-xs">
+                <span className="text-bambu-gray truncate max-w-[100px]">
+                  {printerMap.get(printerId) || `Printer ${printerId}`}
+                </span>
+                <span className={`font-medium ${
+                  acc >= 95 && acc <= 105 ? 'text-bambu-green' :
+                  acc > 105 ? 'text-blue-400' : 'text-orange-400'
+                }`}>
+                  {acc.toFixed(0)}%
+                </span>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
 function FilamentTypesWidget({
   stats,
 }: {
@@ -253,6 +338,12 @@ export function StatsPage() {
       component: <SuccessRateWidget stats={stats} />,
       defaultSize: 1,
     },
+    {
+      id: 'time-accuracy',
+      title: 'Time Accuracy',
+      component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
+      defaultSize: 1,
+    },
     {
       id: 'filament-types',
       title: 'Filament Types',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BVoz8SLN.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DZsA8zAw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DwtaaDcN.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-Qa2rW77Z.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BVoz8SLN.css">
+    <script type="module" crossorigin src="/assets/index-DZsA8zAw.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DwtaaDcN.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff