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:
MartinNYHC
2026-03-01 15:22:27 +01:00
committed by GitHub
18 changed files with 1879 additions and 372 deletions
+115 -13
View File
@@ -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(
+21
View File
@@ -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
+11 -1
View File
@@ -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())
+34 -14
View File
@@ -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."""
+206 -16
View File
@@ -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', () => {
+38 -4
View File
@@ -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(',')}`),
+8
View File
@@ -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 {
+296 -137
View File
@@ -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>
+39
View File
@@ -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>
);
}
+42
View File
@@ -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
+42
View File
@@ -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
+42
View File
@@ -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
+42
View File
@@ -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
+42
View File
@@ -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: 'メンテナンス',
+42
View File
@@ -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
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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`;
}