mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
Merge pull request #561 from aneopsy/statistic-timeframe
[Feature]: Statistics page - Add timeframe filtering, new widgets & fix success rate calculation
This commit is contained in:
@@ -2,6 +2,7 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from datetime import date, datetime, time, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,7 +22,7 @@ from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.spool_usage_history import SpoolUsageHistory
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
|
||||
from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
|
||||
|
||||
@@ -122,6 +123,8 @@ def archive_to_response(
|
||||
async def list_archives(
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
date_from: date | None = Query(None),
|
||||
date_to: date | None = Query(None),
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -132,6 +135,8 @@ async def list_archives(
|
||||
archives = await service.list_archives(
|
||||
printer_id=printer_id,
|
||||
project_id=project_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -149,6 +154,78 @@ async def list_archives(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/slim", response_model=list[ArchiveSlim])
|
||||
async def list_archives_slim(
|
||||
date_from: date | None = Query(None),
|
||||
date_to: date | None = Query(None),
|
||||
limit: int = Query(default=10000, le=50000),
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Lightweight archive listing for stats/dashboard widgets.
|
||||
|
||||
Returns only the fields needed for client-side aggregation,
|
||||
skipping duplicate detection, file paths, and extra_data.
|
||||
"""
|
||||
filters = []
|
||||
if date_from:
|
||||
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
||||
filters.append(PrintArchive.created_at >= dt_from)
|
||||
if date_to:
|
||||
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
|
||||
filters.append(PrintArchive.created_at <= dt_to)
|
||||
|
||||
query = (
|
||||
select(
|
||||
PrintArchive.printer_id,
|
||||
PrintArchive.print_name,
|
||||
PrintArchive.print_time_seconds,
|
||||
PrintArchive.started_at,
|
||||
PrintArchive.completed_at,
|
||||
PrintArchive.filament_used_grams,
|
||||
PrintArchive.filament_type,
|
||||
PrintArchive.filament_color,
|
||||
PrintArchive.status,
|
||||
PrintArchive.cost,
|
||||
PrintArchive.quantity,
|
||||
PrintArchive.created_at,
|
||||
)
|
||||
.where(*filters)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"printer_id": r.printer_id,
|
||||
"print_name": r.print_name,
|
||||
"print_time_seconds": r.print_time_seconds,
|
||||
"actual_time_seconds": (
|
||||
int((r.completed_at - r.started_at).total_seconds())
|
||||
if r.started_at
|
||||
and r.completed_at
|
||||
and r.status == "completed"
|
||||
and (r.completed_at - r.started_at).total_seconds() > 0
|
||||
else None
|
||||
),
|
||||
"filament_used_grams": r.filament_used_grams,
|
||||
"filament_type": r.filament_type,
|
||||
"filament_color": r.filament_color,
|
||||
"status": r.status,
|
||||
"started_at": r.started_at,
|
||||
"completed_at": r.completed_at,
|
||||
"cost": r.cost,
|
||||
"quantity": r.quantity,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[ArchiveResponse])
|
||||
async def search_archives(
|
||||
q: str = Query(..., min_length=2, description="Search query"),
|
||||
@@ -277,7 +354,9 @@ async def rebuild_search_index(
|
||||
|
||||
@router.get("/analysis/failures")
|
||||
async def analyze_failures(
|
||||
days: int = 30,
|
||||
days: int | None = None,
|
||||
date_from: date | None = Query(None),
|
||||
date_to: date | None = Query(None),
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -297,6 +376,8 @@ async def analyze_failures(
|
||||
service = FailureAnalysisService(db)
|
||||
return await service.analyze_failures(
|
||||
days=days,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
printer_id=printer_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
@@ -440,25 +521,42 @@ async def export_stats(
|
||||
|
||||
@router.get("/stats", response_model=ArchiveStats)
|
||||
async def get_archive_stats(
|
||||
date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
|
||||
date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
|
||||
):
|
||||
"""Get statistics across all archives."""
|
||||
# Build date filter conditions
|
||||
base_conditions = []
|
||||
if date_from:
|
||||
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
||||
base_conditions.append(PrintArchive.created_at >= dt_from)
|
||||
if date_to:
|
||||
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
|
||||
base_conditions.append(PrintArchive.created_at <= dt_to)
|
||||
|
||||
# Total counts
|
||||
total_result = await db.execute(select(func.count(PrintArchive.id)))
|
||||
total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
|
||||
total_prints = total_result.scalar() or 0
|
||||
|
||||
successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
|
||||
successful_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
|
||||
)
|
||||
successful_prints = successful_result.scalar() or 0
|
||||
|
||||
failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
|
||||
failed_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
|
||||
)
|
||||
failed_prints = failed_result.scalar() or 0
|
||||
|
||||
# Totals - use actual print time from timestamps (not slicer estimates)
|
||||
# For archives with both started_at and completed_at, calculate actual duration
|
||||
# Fall back to print_time_seconds only for archives without timestamps
|
||||
archives_for_time = await db.execute(
|
||||
select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds)
|
||||
select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
|
||||
*base_conditions
|
||||
)
|
||||
)
|
||||
total_seconds = 0
|
||||
for started_at, completed_at, print_time_seconds in archives_for_time.all():
|
||||
@@ -473,15 +571,17 @@ async def get_archive_stats(
|
||||
total_time = total_seconds / 3600 # Convert to hours
|
||||
|
||||
# Sum filament directly - filament_used_grams already contains the total for the print job
|
||||
filament_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
|
||||
filament_result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
|
||||
)
|
||||
total_filament = filament_result.scalar() or 0
|
||||
|
||||
cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
|
||||
cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
|
||||
total_cost = cost_result.scalar() or 0
|
||||
|
||||
# By filament type (split comma-separated values for multi-material prints)
|
||||
filament_type_result = await db.execute(
|
||||
select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None))
|
||||
select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
|
||||
)
|
||||
prints_by_filament: dict[str, int] = {}
|
||||
for (filament_types,) in filament_type_result.all():
|
||||
@@ -493,7 +593,9 @@ async def get_archive_stats(
|
||||
|
||||
# By printer
|
||||
printer_result = await db.execute(
|
||||
select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
|
||||
select(PrintArchive.printer_id, func.count(PrintArchive.id))
|
||||
.where(*base_conditions)
|
||||
.group_by(PrintArchive.printer_id)
|
||||
)
|
||||
prints_by_printer = {str(k): v for k, v in printer_result.all()}
|
||||
|
||||
@@ -501,7 +603,7 @@ async def get_archive_stats(
|
||||
# Get all completed archives with both estimated and actual times
|
||||
accuracy_result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.status == "completed")
|
||||
.where(PrintArchive.status == "completed", *base_conditions)
|
||||
.where(PrintArchive.print_time_seconds.isnot(None))
|
||||
.where(PrintArchive.started_at.isnot(None))
|
||||
.where(PrintArchive.completed_at.isnot(None))
|
||||
@@ -575,10 +677,10 @@ async def get_archive_stats(
|
||||
total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
|
||||
else:
|
||||
# Print mode: sum up per-print energy from archives
|
||||
energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)))
|
||||
energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
|
||||
total_energy_kwh = energy_kwh_result.scalar() or 0
|
||||
|
||||
energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)))
|
||||
energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
|
||||
total_energy_cost = energy_cost_result.scalar() or 0
|
||||
|
||||
return ArchiveStats(
|
||||
|
||||
@@ -110,6 +110,27 @@ class ArchiveResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ArchiveSlim(BaseModel):
|
||||
"""Lightweight archive response for stats/dashboard widgets."""
|
||||
|
||||
printer_id: int | None
|
||||
print_name: str | None
|
||||
print_time_seconds: int | None
|
||||
actual_time_seconds: int | None = None
|
||||
filament_used_grams: float | None
|
||||
filament_type: str | None
|
||||
filament_color: str | None
|
||||
status: str
|
||||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
cost: float | None
|
||||
quantity: int = 1
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ArchiveStats(BaseModel):
|
||||
total_prints: int
|
||||
successful_prints: int
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,28 +16,47 @@ class FailureAnalysisService:
|
||||
|
||||
async def analyze_failures(
|
||||
self,
|
||||
days: int = 30,
|
||||
days: int | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Analyze failure patterns across archives.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
days: Number of days to analyze (fallback when no date range)
|
||||
date_from: Start date filter (inclusive)
|
||||
date_to: End date filter (inclusive)
|
||||
printer_id: Optional filter by printer
|
||||
project_id: Optional filter by project
|
||||
|
||||
Returns:
|
||||
Dictionary with failure analysis results
|
||||
"""
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
# Build base query
|
||||
base_filter = [PrintArchive.created_at >= cutoff_date]
|
||||
# Build base query — separate date vs non-date filters for trend reuse
|
||||
base_filter = []
|
||||
non_date_filter = []
|
||||
if date_from or date_to:
|
||||
if date_from:
|
||||
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
||||
base_filter.append(PrintArchive.created_at >= dt_from)
|
||||
if date_to:
|
||||
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
|
||||
base_filter.append(PrintArchive.created_at <= dt_to)
|
||||
# Compute effective span for trend
|
||||
range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
|
||||
range_end = dt_to if date_to else datetime.now(timezone.utc)
|
||||
effective_days = max((range_end - range_start).days, 1)
|
||||
else:
|
||||
effective_days = days if days is not None else 30
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
|
||||
base_filter.append(PrintArchive.created_at >= cutoff_date)
|
||||
if printer_id:
|
||||
base_filter.append(PrintArchive.printer_id == printer_id)
|
||||
non_date_filter.append(PrintArchive.printer_id == printer_id)
|
||||
if project_id:
|
||||
base_filter.append(PrintArchive.project_id == project_id)
|
||||
non_date_filter.append(PrintArchive.project_id == project_id)
|
||||
base_filter.extend(non_date_filter)
|
||||
|
||||
# Total counts
|
||||
total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
|
||||
@@ -141,15 +160,16 @@ class FailureAnalysisService:
|
||||
|
||||
# Failure rate trend (by week)
|
||||
trend_data = []
|
||||
for i in range(min(days // 7, 12)): # Up to 12 weeks
|
||||
num_weeks = max(effective_days // 7, 1)
|
||||
for i in range(num_weeks):
|
||||
week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
|
||||
week_start = week_end - timedelta(weeks=1)
|
||||
|
||||
week_filter = base_filter.copy()
|
||||
week_filter[0] = and_(
|
||||
week_filter = [
|
||||
PrintArchive.created_at >= week_start,
|
||||
PrintArchive.created_at < week_end,
|
||||
)
|
||||
*non_date_filter,
|
||||
]
|
||||
|
||||
week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
|
||||
week_failed = await self.db.execute(
|
||||
@@ -174,7 +194,7 @@ class FailureAnalysisService:
|
||||
trend_data.reverse() # Oldest first
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"period_days": effective_days,
|
||||
"total_prints": total_prints,
|
||||
"failed_prints": failed_prints,
|
||||
"failure_rate": round(failure_rate, 1),
|
||||
|
||||
@@ -227,6 +227,156 @@ class TestArchivesAPI:
|
||||
assert "successful_prints" in result
|
||||
|
||||
|
||||
class TestArchivesSlimAPI:
|
||||
"""Integration tests for /api/v1/archives/slim endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_empty(self, async_client: AsyncClient):
|
||||
"""Verify empty list when no archives exist."""
|
||||
response = await async_client.get("/api/v1/archives/slim")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_returns_only_expected_fields(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Verify response contains only slim fields, not full archive data."""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
print_name="Slim Test",
|
||||
status="completed",
|
||||
filament_type="PLA",
|
||||
filament_color="#FF0000",
|
||||
filament_used_grams=50.0,
|
||||
print_time_seconds=3600,
|
||||
cost=1.50,
|
||||
quantity=2,
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/slim")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
item = data[0]
|
||||
|
||||
# Expected fields present
|
||||
assert item["printer_id"] == printer.id
|
||||
assert item["print_name"] == "Slim Test"
|
||||
assert item["status"] == "completed"
|
||||
assert item["filament_type"] == "PLA"
|
||||
assert item["filament_color"] == "#FF0000"
|
||||
assert item["filament_used_grams"] == 50.0
|
||||
assert item["print_time_seconds"] == 3600
|
||||
assert item["cost"] == 1.50
|
||||
assert item["quantity"] == 2
|
||||
assert "created_at" in item
|
||||
|
||||
# Full archive fields must NOT be present
|
||||
assert "id" not in item
|
||||
assert "filename" not in item
|
||||
assert "file_path" not in item
|
||||
assert "file_size" not in item
|
||||
assert "extra_data" not in item
|
||||
assert "notes" not in item
|
||||
assert "tags" not in item
|
||||
assert "photos" not in item
|
||||
assert "thumbnail_path" not in item
|
||||
assert "content_hash" not in item
|
||||
assert "duplicates" not in item
|
||||
assert "duplicate_count" not in item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_computes_actual_time(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Verify actual_time_seconds is computed from started_at/completed_at."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
printer = await printer_factory()
|
||||
started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # 2 hours = 7200s
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
status="completed",
|
||||
started_at=started,
|
||||
completed_at=completed,
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/slim")
|
||||
|
||||
assert response.status_code == 200
|
||||
item = response.json()[0]
|
||||
assert item["actual_time_seconds"] == 7200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_actual_time_null_for_failed(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Verify actual_time_seconds is null for non-completed prints."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
status="failed",
|
||||
started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
|
||||
completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/slim")
|
||||
|
||||
assert response.status_code == 200
|
||||
item = response.json()[0]
|
||||
assert item["actual_time_seconds"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
||||
"""Verify date_from and date_to filters work."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
print_name="Old Print",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
print_name="New Print",
|
||||
created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Filter to only June 2024
|
||||
response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["print_name"] == "New Print"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
||||
"""Verify limit and offset work."""
|
||||
printer = await printer_factory()
|
||||
for i in range(5):
|
||||
await archive_factory(printer.id, print_name=f"Print {i}")
|
||||
|
||||
response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
|
||||
class TestArchiveDataIntegrity:
|
||||
"""Tests for archive data integrity."""
|
||||
|
||||
|
||||
@@ -41,8 +41,70 @@ const mockPrinters = [
|
||||
];
|
||||
|
||||
const mockArchives = [
|
||||
{ id: 1, created_at: '2024-01-01T00:00:00Z', print_name: 'Test Print 1' },
|
||||
{ id: 2, created_at: '2024-01-02T00:00:00Z', print_name: 'Test Print 2' },
|
||||
{
|
||||
id: 1,
|
||||
created_at: '2024-01-01T10:00:00Z',
|
||||
started_at: '2024-01-01T10:00:00Z',
|
||||
completed_at: '2024-01-01T14:30:00Z',
|
||||
print_name: 'Benchy',
|
||||
status: 'completed',
|
||||
printer_id: 1,
|
||||
filament_type: 'PLA',
|
||||
filament_color: '#00FF00',
|
||||
filament_used_grams: 25,
|
||||
actual_time_seconds: 16200,
|
||||
print_time_seconds: 15000,
|
||||
cost: 0.75,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
created_at: '2024-01-02T14:00:00Z',
|
||||
started_at: '2024-01-02T14:00:00Z',
|
||||
completed_at: '2024-01-02T22:00:00Z',
|
||||
print_name: 'Large Vase',
|
||||
status: 'completed',
|
||||
printer_id: 1,
|
||||
filament_type: 'PETG',
|
||||
filament_color: '#FF0000',
|
||||
filament_used_grams: 180,
|
||||
actual_time_seconds: 28800,
|
||||
print_time_seconds: 27000,
|
||||
cost: 5.40,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
created_at: '2024-01-03T08:00:00Z',
|
||||
started_at: '2024-01-03T08:00:00Z',
|
||||
completed_at: null,
|
||||
print_name: 'Failed Bracket',
|
||||
status: 'failed',
|
||||
printer_id: 2,
|
||||
filament_type: 'ABS',
|
||||
filament_color: '#0000FF',
|
||||
filament_used_grams: 10,
|
||||
actual_time_seconds: 3600,
|
||||
print_time_seconds: 7200,
|
||||
cost: 0.30,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
created_at: '2024-01-03T20:00:00Z',
|
||||
started_at: '2024-01-03T20:00:00Z',
|
||||
completed_at: '2024-01-04T02:00:00Z',
|
||||
print_name: 'Phone Stand',
|
||||
status: 'completed',
|
||||
printer_id: 2,
|
||||
filament_type: 'PLA',
|
||||
filament_color: '#00FF00',
|
||||
filament_used_grams: 45,
|
||||
actual_time_seconds: 21600,
|
||||
print_time_seconds: 20000,
|
||||
cost: 1.35,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSettings = {
|
||||
@@ -60,9 +122,19 @@ const mockFailureAnalysis = {
|
||||
'First layer adhesion': 3,
|
||||
'Filament runout': 2,
|
||||
},
|
||||
failures_by_filament: {
|
||||
'ABS': 3,
|
||||
'PLA': 2,
|
||||
},
|
||||
failures_by_printer: {
|
||||
'1': 2,
|
||||
'2': 3,
|
||||
},
|
||||
failures_by_hour: {},
|
||||
recent_failures: [],
|
||||
trend: [
|
||||
{ week: '2024-W01', failure_rate: 6.0 },
|
||||
{ week: '2024-W02', failure_rate: 5.0 },
|
||||
{ week_start: '2024-01-01', total_prints: 50, failed_prints: 3, failure_rate: 6.0 },
|
||||
{ week_start: '2024-01-08', total_prints: 50, failed_prints: 2, failure_rate: 5.0 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -75,13 +147,13 @@ describe('StatsPage', () => {
|
||||
http.get('/api/v1/printers/', () => {
|
||||
return HttpResponse.json(mockPrinters);
|
||||
}),
|
||||
http.get('/api/v1/archives/', () => {
|
||||
http.get('/api/v1/archives/slim', () => {
|
||||
return HttpResponse.json(mockArchives);
|
||||
}),
|
||||
http.get('/api/v1/settings/', () => {
|
||||
return HttpResponse.json(mockSettings);
|
||||
}),
|
||||
http.get('/api/v1/stats/failure-analysis', () => {
|
||||
http.get('/api/v1/archives/analysis/failures', () => {
|
||||
return HttpResponse.json(mockFailureAnalysis);
|
||||
})
|
||||
);
|
||||
@@ -127,7 +199,7 @@ describe('StatsPage', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Filament Used')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.50kg')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.5kg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,7 +210,7 @@ describe('StatsPage', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Success Rate')).toBeInTheDocument();
|
||||
// Success rate should be calculated: 140/150 = 93%
|
||||
// Success rate: 140/(140+10) = 93%
|
||||
expect(screen.getByText('93%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -163,14 +235,6 @@ describe('StatsPage', () => {
|
||||
});
|
||||
|
||||
describe('widgets', () => {
|
||||
it('shows filament types widget', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Filament Types')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows time accuracy widget', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
@@ -186,6 +250,132 @@ describe('StatsPage', () => {
|
||||
expect(screen.getByText('Print Activity')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows failure analysis widget', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failure Analysis')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows printer stats widget', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Printer Stats')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filament trends widget', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Filament Trends')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows records widget', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Records')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('printer stats sub-cards', () => {
|
||||
it('shows prints by printer section', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Prints by Printer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows print duration section', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Print Duration')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows print habits section', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Print Habits')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows print time of day section', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Print Time of Day')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filament trends sub-cards', () => {
|
||||
it('shows by material section', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('By Material')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success by material section', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Success by Material')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows color distribution section', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Color Distribution')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('records widget', () => {
|
||||
it('shows longest print record', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Longest Print')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows heaviest print record', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Heaviest Print')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows most expensive record', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Most Expensive')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success streak record', async () => {
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Success Streak')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
|
||||
@@ -373,6 +373,22 @@ export interface Archive {
|
||||
created_by_username: string | null;
|
||||
}
|
||||
|
||||
export interface ArchiveSlim {
|
||||
printer_id: number | null;
|
||||
print_name: string | null;
|
||||
print_time_seconds: number | null;
|
||||
actual_time_seconds: number | null;
|
||||
filament_used_grams: number | null;
|
||||
filament_type: string | null;
|
||||
filament_color: string | null;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
cost: number | null;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PrintLogEntry {
|
||||
id: number;
|
||||
print_name: string | null;
|
||||
@@ -2489,14 +2505,23 @@ export const api = {
|
||||
request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
|
||||
|
||||
// Archives
|
||||
getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => {
|
||||
getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0, dateFrom?: string, dateTo?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (printerId) params.set('printer_id', String(printerId));
|
||||
if (projectId) params.set('project_id', String(projectId));
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
if (dateFrom) params.set('date_from', dateFrom);
|
||||
if (dateTo) params.set('date_to', dateTo);
|
||||
return request<Archive[]>(`/archives/?${params}`);
|
||||
},
|
||||
getArchivesSlim: (dateFrom?: string, dateTo?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (dateFrom) params.set('date_from', dateFrom);
|
||||
if (dateTo) params.set('date_to', dateTo);
|
||||
const qs = params.toString();
|
||||
return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getArchive: (id: number) => request<Archive>(`/archives/${id}`),
|
||||
searchArchives: (query: string, options?: {
|
||||
printerId?: number;
|
||||
@@ -2536,7 +2561,13 @@ export const api = {
|
||||
request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
|
||||
deleteArchive: (id: number) =>
|
||||
request<void>(`/archives/${id}`, { method: 'DELETE' }),
|
||||
getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
|
||||
getArchiveStats: (options?: { dateFrom?: string; dateTo?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.dateFrom) params.set('date_from', options.dateFrom);
|
||||
if (options?.dateTo) params.set('date_to', options.dateTo);
|
||||
const qs = params.toString();
|
||||
return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// Tag management
|
||||
getTags: () => request<TagInfo[]>('/archives/tags'),
|
||||
renameTag: (oldName: string, newName: string) =>
|
||||
@@ -2550,12 +2581,15 @@ export const api = {
|
||||
}),
|
||||
recalculateCosts: () =>
|
||||
request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
|
||||
getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
|
||||
getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.days) params.set('days', String(options.days));
|
||||
if (options?.dateFrom) params.set('date_from', options.dateFrom);
|
||||
if (options?.dateTo) params.set('date_to', options.dateTo);
|
||||
if (options?.printerId) params.set('printer_id', String(options.printerId));
|
||||
if (options?.projectId) params.set('project_id', String(options.projectId));
|
||||
return request<FailureAnalysis>(`/archives/analysis/failures?${params}`);
|
||||
const qs = params.toString();
|
||||
return request<FailureAnalysis>(`/archives/analysis/failures${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
compareArchives: (archiveIds: number[]) =>
|
||||
request<ArchiveComparison>(`/archives/compare?archive_ids=${archiveIds.join(',')}`),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -7,55 +8,37 @@ import {
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import type { Archive } from '../api/client';
|
||||
import type { ArchiveSlim } from '../api/client';
|
||||
import { MetricToggle, type Metric } from './MetricToggle';
|
||||
import { parseUTCDate } from '../utils/date';
|
||||
import { formatWeight } from '../utils/weight';
|
||||
|
||||
interface FilamentTrendsProps {
|
||||
archives: Archive[];
|
||||
archives: ArchiveSlim[];
|
||||
currency?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
|
||||
const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
|
||||
|
||||
function getDateRange(range: TimeRange): Date {
|
||||
const now = new Date();
|
||||
switch (range) {
|
||||
case '7d':
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case '30d':
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
case '90d':
|
||||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
case '365d':
|
||||
return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||
case 'all':
|
||||
return new Date(0);
|
||||
}
|
||||
}
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const HOUR_SUFFIXES = ['12am', '1am', '2am', '3am', '4am', '5am', '6am', '7am', '8am', '9am', '10am', '11am', '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', '6pm', '7pm', '8pm', '9pm', '10pm', '11pm'];
|
||||
|
||||
export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||
|
||||
// Filter archives by time range
|
||||
const filteredArchives = useMemo(() => {
|
||||
const startDate = getDateRange(timeRange);
|
||||
return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate);
|
||||
}, [archives, timeRange]);
|
||||
export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: FilamentTrendsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');
|
||||
const [colorMetric, setColorMetric] = useState<Metric>('weight');
|
||||
|
||||
// Calculate daily usage data
|
||||
const dailyData = useMemo(() => {
|
||||
const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
|
||||
|
||||
filteredArchives.forEach(archive => {
|
||||
archives.forEach(archive => {
|
||||
const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
|
||||
// Use local date string for grouping
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
@@ -73,25 +56,69 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
...d,
|
||||
dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
}));
|
||||
}, [filteredArchives]);
|
||||
}, [archives]);
|
||||
|
||||
// Calculate weekly aggregated data for longer time ranges
|
||||
// Compute effective span in days from props or archive spread
|
||||
const spanDays = useMemo(() => {
|
||||
if (dateFrom && dateTo) {
|
||||
return Math.max((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;
|
||||
}
|
||||
if (dateFrom) {
|
||||
return Math.max((Date.now() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;
|
||||
}
|
||||
if (archives.length < 2) return 0;
|
||||
const times = archives.map(a => new Date(a.completed_at || a.created_at).getTime());
|
||||
return (Math.max(...times) - Math.min(...times)) / 86400000;
|
||||
}, [archives, dateFrom, dateTo]);
|
||||
|
||||
// Calculate hourly data for short timeframes (≤ 7 days)
|
||||
const hourlyData = useMemo(() => {
|
||||
if (spanDays > 7) return [];
|
||||
|
||||
const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
|
||||
const multiDay = spanDays > 1;
|
||||
|
||||
archives.forEach(archive => {
|
||||
const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
|
||||
const h = date.getHours();
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}`;
|
||||
|
||||
const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
|
||||
existing.filament += archive.filament_used_grams || 0;
|
||||
existing.cost += archive.cost || 0;
|
||||
existing.prints += archive.quantity || 1;
|
||||
dataMap.set(key, existing);
|
||||
});
|
||||
|
||||
return Array.from(dataMap.values())
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.map(d => {
|
||||
const [datePart, hourPart] = d.date.split('T');
|
||||
const dt = new Date(datePart);
|
||||
const h = parseInt(hourPart, 10);
|
||||
const label = multiDay
|
||||
? `${DAY_NAMES[dt.getDay()]} ${HOUR_SUFFIXES[h]}`
|
||||
: HOUR_SUFFIXES[h];
|
||||
return { ...d, dateLabel: label };
|
||||
});
|
||||
}, [archives, spanDays]);
|
||||
|
||||
// Calculate weekly aggregated data when there are many daily points
|
||||
const weeklyData = useMemo(() => {
|
||||
if (timeRange === '7d' || timeRange === '30d') return dailyData;
|
||||
if (dailyData.length <= 60) return dailyData;
|
||||
|
||||
const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
|
||||
|
||||
filteredArchives.forEach(archive => {
|
||||
const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
|
||||
// Get week start (Sunday)
|
||||
dailyData.forEach(day => {
|
||||
const date = new Date(day.date);
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay());
|
||||
const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
|
||||
existing.filament += archive.filament_used_grams || 0;
|
||||
existing.cost += archive.cost || 0;
|
||||
existing.prints += archive.quantity || 1;
|
||||
existing.filament += day.filament;
|
||||
existing.cost += day.cost;
|
||||
existing.prints += day.prints;
|
||||
dataMap.set(key, existing);
|
||||
});
|
||||
|
||||
@@ -102,13 +129,13 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
|
||||
...d,
|
||||
}));
|
||||
}, [filteredArchives, dailyData, timeRange]);
|
||||
}, [dailyData]);
|
||||
|
||||
// Usage by filament type
|
||||
const filamentTypeData = useMemo(() => {
|
||||
const dataMap = new Map<string, number>();
|
||||
|
||||
filteredArchives.forEach(archive => {
|
||||
archives.forEach(archive => {
|
||||
const type = archive.filament_type || 'Unknown';
|
||||
// Handle multiple types (e.g., "PLA, PETG")
|
||||
const types = type.split(', ');
|
||||
@@ -121,80 +148,119 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
return Array.from(dataMap.entries())
|
||||
.map(([name, value]) => ({ name, value: Math.round(value) }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, [filteredArchives]);
|
||||
|
||||
// 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<string, number>();
|
||||
archives.forEach(archive => {
|
||||
const type = archive.filament_type || 'Unknown';
|
||||
const types = type.split(', ');
|
||||
types.forEach(t => {
|
||||
dataMap.set(t, (dataMap.get(t) || 0) + 1);
|
||||
});
|
||||
});
|
||||
return Array.from(dataMap.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, [archives]);
|
||||
|
||||
// Usage by filament type (print time in hours)
|
||||
const filamentTypeTimeData = useMemo(() => {
|
||||
const dataMap = new Map<string, number>();
|
||||
archives.forEach(archive => {
|
||||
const type = archive.filament_type || 'Unknown';
|
||||
const types = type.split(', ');
|
||||
const seconds = (archive.actual_time_seconds || archive.print_time_seconds || 0) / types.length;
|
||||
types.forEach(t => {
|
||||
dataMap.set(t, (dataMap.get(t) || 0) + seconds);
|
||||
});
|
||||
});
|
||||
return Array.from(dataMap.entries())
|
||||
.map(([name, seconds]) => ({ name, value: Math.round((seconds / 3600) * 10) / 10 }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, [archives]);
|
||||
|
||||
// Success rate by filament type
|
||||
const filamentSuccessData = useMemo(() => {
|
||||
const map = new Map<string, { completed: number; failed: number }>();
|
||||
archives.forEach(a => {
|
||||
if (a.status !== 'completed' && a.status !== 'failed') return;
|
||||
const types = (a.filament_type || 'Unknown').split(', ');
|
||||
types.forEach(type => {
|
||||
const entry = map.get(type) || { completed: 0, failed: 0 };
|
||||
if (a.status === 'completed') entry.completed++;
|
||||
else entry.failed++;
|
||||
map.set(type, entry);
|
||||
});
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.filter(([, v]) => v.completed + v.failed >= 2)
|
||||
.map(([name, v]) => {
|
||||
const total = v.completed + v.failed;
|
||||
const rate = Math.round((v.completed / total) * 100);
|
||||
return { name, rate, total };
|
||||
})
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
}, [archives]);
|
||||
|
||||
// Color distribution
|
||||
const colorData = useMemo(() => {
|
||||
const colorMap = new Map<string, { count: number; weight: number }>();
|
||||
|
||||
archives.forEach(a => {
|
||||
if (!a.filament_color) return;
|
||||
const colors = a.filament_color.split(',').map(c => c.trim());
|
||||
const weightPerColor = (a.filament_used_grams || 0) / colors.length;
|
||||
|
||||
colors.forEach(hex => {
|
||||
const entry = colorMap.get(hex) || { count: 0, weight: 0 };
|
||||
entry.count++;
|
||||
entry.weight += weightPerColor;
|
||||
colorMap.set(hex, entry);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(colorMap.entries())
|
||||
.map(([hex, data]) => ({
|
||||
hex,
|
||||
value: colorMetric === 'prints' ? data.count : Math.round(data.weight),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, [archives, colorMetric]);
|
||||
|
||||
const activeFilamentTypeData =
|
||||
filamentTypeMetric === 'weight' ? filamentTypeData :
|
||||
filamentTypeMetric === 'prints' ? filamentTypePrintData :
|
||||
filamentTypeTimeData;
|
||||
|
||||
const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
|
||||
const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
|
||||
const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
|
||||
const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);
|
||||
const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Time Range Selector */}
|
||||
<div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-2">
|
||||
<h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
|
||||
<div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
|
||||
{(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
timeRange === range
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{range === 'all' ? 'All' : range.replace('d', 'D')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
|
||||
<div className="grid grid-cols-3 gap-2 max-[640px]:grid-cols-1">
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-bambu-gray leading-none">Period Filament</p>
|
||||
<p className="text-2xl font-bold text-white leading-none">{(totalFilament / 1000).toFixed(2)}kg</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-bambu-gray leading-none">{t('stats.periodFilament')}</p>
|
||||
<p className="text-2xl font-bold text-white leading-none">{formatWeight(totalFilament)}</p>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
|
||||
<p className="text-xs text-bambu-gray">{printerCount} {t('nav.printers').toLowerCase()}</p>
|
||||
</div>
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-bambu-gray leading-none">Period Cost</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-bambu-gray leading-none">{t('stats.periodCost')}</p>
|
||||
<p className="text-2xl font-bold text-white leading-none">{currency}{totalCost.toFixed(2)}</p>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">{totalPrints} prints</p>
|
||||
<p className="text-xs text-bambu-gray">{totalPrints} {t('common.prints')}</p>
|
||||
</div>
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-bambu-gray leading-none">Avg per Print</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-bambu-gray leading-none">{t('stats.avgPerPrint')}</p>
|
||||
<p className="text-2xl font-bold text-white leading-none">
|
||||
{totalPrints > 0
|
||||
? (totalFilament / totalPrints).toFixed(0)
|
||||
@@ -210,7 +276,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
{/* Usage Over Time Chart */}
|
||||
{chartData.length > 0 ? (
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
|
||||
<h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.usageOverTime')}</h4>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
@@ -253,21 +319,24 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
|
||||
No data for selected time range
|
||||
{t('stats.noPrintDataInRange')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Filament Type Distribution */}
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
|
||||
{filamentTypeData.length > 0 ? (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-bambu-gray">{t('stats.byMaterial')}</h4>
|
||||
<MetricToggle value={filamentTypeMetric} onChange={setFilamentTypeMetric} />
|
||||
</div>
|
||||
{activeFilamentTypeData.length > 0 ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width={160} height={160}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filamentTypeData}
|
||||
data={activeFilamentTypeData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={40}
|
||||
@@ -275,7 +344,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{filamentTypeData.map((_, index) => (
|
||||
{activeFilamentTypeData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -285,13 +354,18 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value) => [`${value ?? 0}g`, 'Usage']}
|
||||
formatter={(value) => [
|
||||
filamentTypeMetric === 'weight' ? formatWeight(Number(value ?? 0)) :
|
||||
filamentTypeMetric === 'time' ? `${Number(value ?? 0)}h` :
|
||||
`${value ?? 0}`,
|
||||
filamentTypeMetric === 'weight' ? 'Usage' : filamentTypeMetric === 'time' ? 'Time' : 'Prints',
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
{filamentTypeData.map((entry, index) => {
|
||||
const total = filamentTypeData.reduce((sum, e) => sum + e.value, 0);
|
||||
{activeFilamentTypeData.map((entry, index) => {
|
||||
const total = activeFilamentTypeData.reduce((sum, e) => sum + e.value, 0);
|
||||
const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div key={entry.name} className="flex items-center gap-2 text-sm">
|
||||
@@ -300,7 +374,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-white truncate flex-1">{entry.name}</span>
|
||||
<span className="text-bambu-gray flex-shrink-0">{percent}%</span>
|
||||
<span className="text-bambu-gray flex-shrink-0">
|
||||
{filamentTypeMetric === 'weight' ? formatWeight(entry.value) :
|
||||
filamentTypeMetric === 'time' ? `${entry.value}h` :
|
||||
entry.value} · {percent}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -308,34 +386,115 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
||||
No filament data
|
||||
{t('stats.noFilamentData')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monthly Comparison */}
|
||||
{/* Success by Material */}
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={monthlyComparison}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
||||
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
|
||||
<YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
name === 'filament' ? `${value ?? 0}g` : name === 'cost' ? `${currency}${Number(value ?? 0).toFixed(2)}` : value ?? 0,
|
||||
name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<h4 className="text-sm font-medium text-bambu-gray mb-4">{t('stats.filamentSuccess')}</h4>
|
||||
{filamentSuccessData.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{filamentSuccessData.map(d => (
|
||||
<div key={d.name} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-white truncate w-20 flex-shrink-0">{d.name}</span>
|
||||
<div className="flex-1 h-1.5 bg-bambu-dark-secondary rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
d.rate >= 90 ? 'bg-status-ok' : d.rate >= 70 ? 'bg-status-warning' : 'bg-status-error'
|
||||
}`}
|
||||
style={{ width: `${d.rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`font-medium flex-shrink-0 tabular-nums ${
|
||||
d.rate >= 90 ? 'text-status-ok' : d.rate >= 70 ? 'text-status-warning' : 'text-status-error'
|
||||
}`}>
|
||||
{d.rate}%
|
||||
</span>
|
||||
<span className="text-bambu-gray flex-shrink-0 text-xs">({d.total})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
||||
{t('stats.noArchiveData')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Distribution */}
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-bambu-gray">{t('stats.colorDistribution')}</h4>
|
||||
<MetricToggle value={colorMetric} onChange={setColorMetric} exclude={['time']} />
|
||||
</div>
|
||||
{colorData.length > 0 ? (() => {
|
||||
const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0);
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mx-auto" style={{ width: 160, height: 160 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={colorData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={70}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{colorData.map((entry, index) => (
|
||||
<Cell key={`color-${index}`} fill={entry.hex} stroke="#1a1a1a" strokeWidth={1} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value) => [
|
||||
colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`,
|
||||
colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">
|
||||
{colorMetric === 'weight' ? formatWeight(colorTotal) : colorTotal}
|
||||
</span>
|
||||
<span className="text-[10px] text-bambu-gray">
|
||||
{colorData.length} {colorData.length === 1 ? 'color' : 'colors'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
|
||||
{colorData.slice(0, 8).map((entry) => {
|
||||
const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div key={entry.hex} className="flex items-center gap-1.5 text-xs min-w-0">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-white/20"
|
||||
style={{ backgroundColor: entry.hex }} />
|
||||
<span className="text-bambu-gray truncate">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{colorData.length > 8 && (
|
||||
<p className="text-[10px] text-bambu-gray mt-1 text-center">+{colorData.length - 8} more</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
||||
{t('stats.noColorData')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type Metric = 'weight' | 'prints' | 'time';
|
||||
|
||||
const METRICS: Metric[] = ['weight', 'prints', 'time'];
|
||||
|
||||
interface MetricToggleProps {
|
||||
value: Metric;
|
||||
onChange: (metric: Metric) => void;
|
||||
exclude?: Metric[];
|
||||
}
|
||||
|
||||
export function MetricToggle({ value, onChange, exclude }: MetricToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const labels: Record<Metric, string> = {
|
||||
weight: t('stats.filamentByWeight'),
|
||||
prints: t('stats.filamentByPrints'),
|
||||
time: t('stats.filamentByTime'),
|
||||
};
|
||||
|
||||
const metrics = exclude ? METRICS.filter(m => !exclude.includes(m)) : METRICS;
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 bg-bambu-dark rounded-lg p-0.5">
|
||||
{metrics.map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => onChange(m)}
|
||||
className={`px-2 py-0.5 text-xs rounded-md transition-colors ${
|
||||
value === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{labels[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 'メンテナンス',
|
||||
|
||||
@@ -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
|
||||
|
||||
+698
-187
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user