+
+ {/* Hour labels row */}
+
+ {HOUR_LABELS.map((label, i) => (
+
+ {label}
-
+
+ {/* Day rows */}
+ {days.map(day => (
+
+
+ {day.label}
+ {Array.from({ length: 24 }, (_, hour) => {
+ const count = hourlyCounts[`${day.key}-${hour}`] || 0;
+ return (
+
+ );
+ })}
- );
- })}
- {hasMore && (
-
- {t('common.more', { count: sortedEntries.length - maxEntries })}
-
- )}
+ ))}
+
+
+ {/* Legend */}
+
);
}
@@ -381,43 +454,236 @@ function FilamentTypesWidget({
function PrintActivityWidget({
printDates,
size = 2,
+ dateFrom,
+ dateTo,
}: {
printDates: string[];
size?: 1 | 2 | 4;
+ dateFrom?: string;
+ dateTo?: string;
}) {
- // Show more months when widget is larger - cell size auto-calculated
- const months = size === 1 ? 3 : size === 2 ? 6 : 12;
+ 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;
+ }
+ return Infinity;
+ }, [dateFrom, dateTo]);
+
+ if (spanDays <= 7 && dateFrom && dateTo) {
+ return
;
+ }
+
+ // Calculate months from the timeframe span, fall back to size-based default for all-time
+ const sizeDefault = size === 1 ? 3 : size === 2 ? 6 : 12;
+ const months = spanDays === Infinity
+ ? sizeDefault
+ : Math.max(1, Math.ceil(spanDays / 30));
return
;
}
-function PrintsByPrinterWidget({
+function PrinterStatsWidget({
stats,
+ archives,
printerMap,
- t,
}: {
stats: { prints_by_printer: Record
} | undefined;
+ archives: ArchiveSlim[];
printerMap: Map;
- t: (key: string) => string;
}) {
- if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
- return {t('stats.noPrinterData')}
;
- }
+ const { t } = useTranslation();
+ const [printerMetric, setPrinterMetric] = useState('weight');
+ const [habitsMetric, setHabitsMetric] = useState('weight');
+
+ // Per-printer data
+ const printerData = useMemo(() => {
+ const map = new Map();
+ if (stats?.prints_by_printer) {
+ Object.entries(stats.prints_by_printer).forEach(([id, count]) => {
+ const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };
+ entry.prints = count;
+ map.set(id, entry);
+ });
+ }
+ archives.forEach(a => {
+ if (!a.printer_id) return;
+ const id = String(a.printer_id);
+ const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };
+ entry.weight += a.filament_used_grams || 0;
+ entry.time += a.actual_time_seconds || a.print_time_seconds || 0;
+ if (!stats?.prints_by_printer) entry.prints++;
+ map.set(id, entry);
+ });
+ return Array.from(map.entries())
+ .map(([id, v]) => ({
+ name: printerMap.get(id) || `${t('common.printer')} ${id}`,
+ value: printerMetric === 'prints' ? v.prints :
+ printerMetric === 'weight' ? Math.round(v.weight) :
+ Math.round((v.time / 3600) * 10) / 10,
+ }))
+ .sort((a, b) => b.value - a.value);
+ }, [stats, archives, printerMap, printerMetric, t]);
+
+ // Hourly distribution (time of day)
+ const hourlyData = useMemo(() => {
+ const hours = Array.from({ length: 24 }, (_, i) => ({
+ hour: i,
+ label: HOUR_LABELS[i],
+ total: 0,
+ failures: 0,
+ }));
+
+ archives.forEach(a => {
+ if (!a.started_at) return;
+ const date = parseUTCDate(a.started_at);
+ if (!date) return;
+ const h = date.getHours();
+ hours[h].total++;
+ if (a.status === 'failed') {
+ hours[h].failures++;
+ }
+ });
+
+ return hours;
+ }, [archives]);
+
+ // Duration distribution
+ const durationData = useMemo(() => {
+ const counts = DURATION_BUCKETS.map(b => ({ name: b.key, count: 0 }));
+ archives.forEach(a => {
+ const seconds = a.actual_time_seconds || a.print_time_seconds;
+ if (!seconds || seconds <= 0) return;
+ for (let i = 0; i < DURATION_BUCKETS.length; i++) {
+ if (seconds <= DURATION_BUCKETS[i].max) {
+ counts[i].count++;
+ break;
+ }
+ }
+ });
+ return counts;
+ }, [archives]);
+
+ // Habits (avg per day-of-week)
+ const habitsData = useMemo(() => {
+ const dayValues = [0, 0, 0, 0, 0, 0, 0];
+ const weeksSet = new Set();
+ archives.forEach(a => {
+ const date = parseUTCDate(a.created_at) || new Date(a.created_at);
+ let day = date.getDay() - 1;
+ if (day < 0) day = 6;
+ if (habitsMetric === 'prints') dayValues[day]++;
+ else if (habitsMetric === 'weight') dayValues[day] += a.filament_used_grams || 0;
+ else dayValues[day] += (a.actual_time_seconds || a.print_time_seconds || 0) / 3600;
+ const weekStart = new Date(date);
+ weekStart.setDate(date.getDate() - ((date.getDay() + 6) % 7));
+ weeksSet.add(`${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`);
+ });
+ const numWeeks = Math.max(weeksSet.size, 1);
+ return DAY_LABELS.map((name, i) => ({
+ name,
+ avg: Math.round((dayValues[i] / numWeeks) * 10) / 10,
+ }));
+ }, [archives, habitsMetric]);
+
+ const metricStyle = (m: Metric) => ({
+ unit: m === 'weight' ? 'g' : m === 'time' ? 'h' : '',
+ color: m === 'weight' ? '#00ae42' : m === 'time' ? '#3b82f6' : '#f59e0b',
+ });
+ const ps = metricStyle(printerMetric);
+ const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints');
+ const hs = metricStyle(habitsMetric);
+ const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints');
return (
-
- {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
-
-
-
-
- {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
-
-
{count} {t('common.prints')}
-
+
+ {/* By Printer */}
+
+
+
{t('stats.printsByPrinter')}
+
- ))}
+ {printerData.length > 0 ? (
+
+
+
+
+
+ [
+ printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${ps.unit}`,
+ pLabel,
+ ]}
+ />
+
+
+
+ ) : (
+
{t('stats.noPrinterData')}
+ )}
+
+
+
+ {/* Print Duration */}
+
+
{t('stats.printDuration')}
+ {archives.length > 0 ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
{t('stats.noArchiveData')}
+ )}
+
+
+ {/* Print Habits */}
+
+
+
{t('stats.printHabits')}
+
+
+ {archives.length > 0 ? (
+
+
+
+
+
+ [`${v ?? 0}${hs.unit}`, hLabel]} />
+
+
+
+ ) : (
+
{t('stats.noArchiveData')}
+ )}
+
+
+ {/* Print Time of Day */}
+
+
{t('stats.printTimeOfDay')}
+ {archives.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
{t('stats.noArchiveData')}
+ )}
+
+
);
}
@@ -425,22 +691,33 @@ function PrintsByPrinterWidget({
function FilamentTrendsWidget({
archives,
currency,
- t,
+ dateFrom,
+ dateTo,
}: {
archives: Parameters
[0]['archives'];
currency: string;
- t: (key: string) => string;
+ dateFrom?: string;
+ dateTo?: string;
}) {
+ const { t } = useTranslation();
if (!archives || archives.length === 0) {
return {t('stats.noPrintData')}
;
}
- return ;
+ return ;
}
-function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record) => string }) {
+function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: {
+ size?: 1 | 2 | 4;
+ dateFrom?: string;
+ dateTo?: string;
+}) {
+ const { t } = useTranslation();
+ const hasDateRange = !!(dateFrom || dateTo);
const { data: analysis, isLoading } = useQuery({
- queryKey: ['failureAnalysis'],
- queryFn: () => api.getFailureAnalysis({ days: 30 }),
+ queryKey: ['failureAnalysis', dateFrom, dateTo],
+ queryFn: () => api.getFailureAnalysis({
+ ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),
+ }),
});
if (isLoading) {
@@ -452,7 +729,7 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str
}
if (!analysis || analysis.total_prints === 0) {
- return {t('stats.noPrintDataLast30Days')}
;
+ return {hasDateRange ? t('stats.noPrintDataInRange') : t('stats.noPrintDataLast30Days')}
;
}
// Show more reasons when expanded
@@ -518,6 +795,131 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str
);
}
+function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; currency: string }) {
+ const { t } = useTranslation();
+
+ const records = useMemo(() => {
+ const result: Array<{
+ icon: typeof Clock;
+ iconColor: string;
+ label: string;
+ value: string;
+ detail: string | null;
+ }> = [];
+
+ if (archives.length === 0) return result;
+
+ // Find the archive with the highest value for a given field
+ const findMax = (getter: (a: ArchiveSlim) => number | null | undefined): { archive: ArchiveSlim | null; value: number } => {
+ let best: ArchiveSlim | null = null;
+ let bestVal = 0;
+ archives.forEach(a => {
+ const v = getter(a);
+ if (v && v > bestVal) { bestVal = v; best = a; }
+ });
+ return { archive: best, value: bestVal };
+ };
+
+ const longest = findMax(a => a.actual_time_seconds);
+ if (longest.archive) {
+ result.push({
+ icon: Clock, iconColor: 'text-blue-400', label: t('stats.longestPrint'),
+ value: formatDuration(longest.value),
+ detail: longest.archive.print_name || null,
+ });
+ }
+
+ const heaviest = findMax(a => a.filament_used_grams);
+ if (heaviest.archive) {
+ result.push({
+ icon: Package, iconColor: 'text-orange-400', label: t('stats.heaviestPrint'),
+ value: formatWeight(heaviest.value),
+ detail: heaviest.archive.print_name || null,
+ });
+ }
+
+ const costliest = findMax(a => a.cost);
+ if (costliest.archive) {
+ result.push({
+ icon: DollarSign, iconColor: 'text-green-400', label: t('stats.mostExpensivePrint'),
+ value: `${currency}${costliest.value.toFixed(2)}`,
+ detail: costliest.archive.print_name || null,
+ });
+ }
+
+ // Busiest day
+ const dayCounts = new Map();
+ archives.forEach(a => {
+ const date = parseUTCDate(a.created_at) || new Date(a.created_at);
+ const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+ dayCounts.set(key, (dayCounts.get(key) || 0) + 1);
+ });
+ let busiestDay = '';
+ let busiestCount = 0;
+ dayCounts.forEach((count, day) => {
+ if (count > busiestCount) {
+ busiestCount = count;
+ busiestDay = day;
+ }
+ });
+ if (busiestCount > 1) {
+ result.push({
+ icon: Calendar,
+ iconColor: 'text-purple-400',
+ label: t('stats.busiestDay'),
+ value: `${busiestCount} ${t('common.prints')}`,
+ detail: (() => { const [y, m, d] = busiestDay.split('-').map(Number); return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); })(),
+ });
+ }
+
+ // Success streak
+ const sorted = [...archives]
+ .filter(a => a.status === 'completed' || a.status === 'failed')
+ .sort((a, b) => new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime());
+ let streak = 0;
+ for (const a of sorted) {
+ if (a.status === 'completed') streak++;
+ else break;
+ }
+ if (streak > 0) {
+ result.push({
+ icon: Zap,
+ iconColor: 'text-yellow-400',
+ label: t('stats.successStreak'),
+ value: `${streak}`,
+ detail: streak === 1 ? t('stats.streakPrint') : t('stats.streakPrints', { count: streak }),
+ });
+ }
+
+ return result;
+ }, [archives, currency, t]);
+
+ if (records.length === 0) {
+ return {t('stats.noArchiveData')}
;
+ }
+
+ return (
+
+ {records.map((record, i) => (
+
+
+
+
+
+
{record.label}
+
+ {record.value}
+ {record.detail && (
+ {record.detail}
+ )}
+
+
+
+ ))}
+
+ );
+}
+
export function StatsPage() {
const { t } = useTranslation();
const { showToast } = useToast();
@@ -527,12 +929,35 @@ export function StatsPage() {
const [dashboardKey, setDashboardKey] = useState(0);
const [hiddenCount, setHiddenCount] = useState(0);
const [isRecalculating, setIsRecalculating] = useState(false);
+ const [timeframe, setTimeframe] = useState(() => {
+ try {
+ const saved = localStorage.getItem('bambusy-stats-timeframe');
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ if (parsed.preset) return parsed;
+ }
+ } catch { /* ignore */ }
+ return { preset: 'all-time', dateFrom: undefined, dateTo: undefined };
+ });
+ const [showTimeframePicker, setShowTimeframePicker] = useState(false);
+
+ // Persist timeframe selection
+ useEffect(() => {
+ localStorage.setItem('bambusy-stats-timeframe', JSON.stringify(timeframe));
+ }, [timeframe]);
+
+ const effectiveDateRange = useMemo(() => {
+ if (timeframe.preset === 'custom') {
+ return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo };
+ }
+ return computeDateRange(timeframe.preset);
+ }, [timeframe]);
// Read hidden count from localStorage
useEffect(() => {
const updateHiddenCount = () => {
try {
- const saved = localStorage.getItem('bambusy-dashboard-layout');
+ const saved = localStorage.getItem('bambusy-dashboard-layout-v2');
if (saved) {
const layout = JSON.parse(saved);
setHiddenCount(layout.hidden?.length || 0);
@@ -545,7 +970,7 @@ export function StatsPage() {
// Listen for storage changes
window.addEventListener('storage', updateHiddenCount);
// Also poll for changes (since storage event doesn't fire for same-tab changes)
- const interval = setInterval(updateHiddenCount, 500);
+ const interval = setInterval(updateHiddenCount, 2000);
return () => {
window.removeEventListener('storage', updateHiddenCount);
clearInterval(interval);
@@ -553,8 +978,11 @@ export function StatsPage() {
}, [dashboardKey]);
const { data: stats, isLoading, refetch: refetchStats } = useQuery({
- queryKey: ['archiveStats'],
- queryFn: api.getArchiveStats,
+ queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
+ queryFn: () => api.getArchiveStats({
+ dateFrom: effectiveDateRange.dateFrom,
+ dateTo: effectiveDateRange.dateTo,
+ }),
});
const { data: printers } = useQuery({
@@ -562,9 +990,9 @@ export function StatsPage() {
queryFn: api.getPrinters,
});
- const { data: archives } = useQuery({
- queryKey: ['archives'],
- queryFn: () => api.getArchives(undefined, undefined, 1000, 0),
+ const { data: archives, refetch: refetchArchives } = useQuery({
+ queryKey: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
+ queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo),
});
const { data: settings } = useQuery({
@@ -595,7 +1023,7 @@ export function StatsPage() {
setIsRecalculating(true);
try {
const result = await api.recalculateCosts();
- await refetchStats();
+ await Promise.all([refetchStats(), refetchArchives()]);
showToast(t('stats.recalculatedCosts', { count: result.updated }));
} catch {
showToast(t('stats.recalculateFailed'), 'error');
@@ -606,7 +1034,7 @@ export function StatsPage() {
const currency = getCurrencySymbol(settings?.currency || 'USD');
const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
- const printDates = archives?.map((a) => a.created_at) || [];
+ const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);
if (isLoading) {
return (
@@ -623,54 +1051,53 @@ export function StatsPage() {
{
id: 'quick-stats',
title: t('stats.quickStats'),
- component: ,
+ component: ,
defaultSize: 2,
},
{
id: 'success-rate',
title: t('stats.successRate'),
- component: (size) => ,
+ component: (size) => ,
defaultSize: 1,
},
{
id: 'time-accuracy',
title: t('stats.timeAccuracy'),
- component: (size) => ,
- defaultSize: 1,
- },
- {
- id: 'filament-types',
- title: t('stats.filamentTypes'),
- component: (size) => ,
+ component: (size) => ,
defaultSize: 1,
},
{
id: 'failure-analysis',
title: t('stats.failureAnalysis'),
- component: (size) => ,
+ component: (size) => ,
defaultSize: 1,
},
{
id: 'print-activity',
title: t('stats.printActivity'),
- component: (size) => ,
+ component: (size) => ,
defaultSize: 2,
},
{
- id: 'prints-by-printer',
- title: t('stats.printsByPrinter'),
- component: ,
- defaultSize: 2,
+ id: 'records',
+ title: t('stats.records'),
+ component: ,
+ defaultSize: 1,
+ },
+ {
+ id: 'printer-stats',
+ title: t('stats.printerStats'),
+ component: ,
+ defaultSize: 4,
},
{
id: 'filament-trends',
title: t('stats.filamentTrends'),
- component: ,
+ component: ,
defaultSize: 4,
},
];
-
return (
@@ -696,7 +1123,7 @@ export function StatsPage() {
)}
+ {/* Timeframe Selector */}
+
+
+
+ {showTimeframePicker && (
+ <>
+
setShowTimeframePicker(false)}
+ />
+
+ {TIMEFRAME_PRESETS.map((preset) => (
+
+ ))}
+
+
+
+
+
+ {timeframe.preset === 'custom' && (
+
+
+
+ setTimeframe(prev => ({ ...prev, dateFrom: e.target.value || undefined }))}
+ className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]"
+ />
+
+
+
+ setTimeframe(prev => ({ ...prev, dateTo: e.target.value || undefined }))}
+ className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]"
+ />
+
+
+
+ )}
+
+ >
+ )}
+
diff --git a/frontend/src/utils/weight.ts b/frontend/src/utils/weight.ts
new file mode 100644
index 000000000..f424ed500
--- /dev/null
+++ b/frontend/src/utils/weight.ts
@@ -0,0 +1,11 @@
+export function formatWeight(grams: number): string {
+ if (grams >= 1_000_000) {
+ const tonnes = grams / 1_000_000;
+ return `${tonnes % 1 === 0 ? tonnes.toFixed(0) : tonnes.toFixed(1)}t`;
+ }
+ if (grams >= 1000) {
+ const kg = grams / 1000;
+ return `${kg % 1 === 0 ? kg.toFixed(0) : kg.toFixed(1)}kg`;
+ }
+ return `${Math.round(grams)}g`;
+}