mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Added calculation for print time accuracy; Added duplicate detection and filter
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user