feat(stats): add hourly heatmap for short timeframes and fix timezone bugs

Print Activity now shows an hourly heatmap (hours x days) for timeframes
≤7 days and dynamically adjusts calendar months for longer ranges.
Adds hourly granularity to Filament Trends, persists timeframe selection,
fixes optional chaining in QuickStatsWidget, and fixes UTC/local timezone
mismatches in date key generation. Also hardens the /archives/slim limit
param and fixes empty query string handling in API client.
This commit is contained in:
AneoPsy
2026-03-01 03:43:40 -10:00
parent 168c00f47d
commit ff3d3a82a7
5 changed files with 209 additions and 21 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ async def list_archives(
async def list_archives_slim(
date_from: date | None = Query(None),
date_to: date | None = Query(None),
limit: int = 10000,
limit: int = Query(default=10000, le=50000),
offset: int = 0,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
@@ -133,8 +133,8 @@ const mockFailureAnalysis = {
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 },
],
};
+4 -2
View File
@@ -2519,7 +2519,8 @@ export const api = {
const params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
return request<ArchiveSlim[]>(`/archives/slim?${params}`);
const qs = params.toString();
return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
},
getArchive: (id: number) => request<Archive>(`/archives/${id}`),
searchArchives: (query: string, options?: {
@@ -2587,7 +2588,8 @@ export const api = {
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(',')}`),
+52 -2
View File
@@ -20,11 +20,16 @@ import { formatWeight } from '../utils/weight';
interface FilamentTrendsProps {
archives: ArchiveSlim[];
currency?: string;
dateFrom?: string;
dateTo?: string;
}
const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
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 = '$', dateFrom, dateTo }: FilamentTrendsProps) {
const { t } = useTranslation();
const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');
const [colorMetric, setColorMetric] = useState<Metric>('weight');
@@ -53,6 +58,51 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
}));
}, [archives]);
// 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 (dailyData.length <= 60) return dailyData;
@@ -184,7 +234,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
filamentTypeMetric === 'prints' ? filamentTypePrintData :
filamentTypeTimeData;
const chartData = weeklyData;
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);
+150 -14
View File
@@ -135,11 +135,11 @@ function QuickStatsWidget({
const items = [
{ icon: Package, color: 'text-bambu-green', label: t('stats.totalPrints'), value: `${stats?.total_prints || 0}` },
{ icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours.toFixed(1) || 0}h` },
{ icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours?.toFixed(1) ?? '0'}h` },
{ icon: Package, color: 'text-orange-400', label: t('stats.filamentUsed'), value: formatWeight(stats?.total_filament_grams || 0) },
{ icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost.toFixed(2) || '0.00'}` },
{ icon: Zap, color: 'text-yellow-400', label: t('stats.energyUsed'), value: `${stats?.total_energy_kwh.toFixed(3) || '0.000'} kWh` },
{ icon: DollarSign, color: 'text-yellow-500', label: t('stats.energyCost'), value: `${currency} ${stats?.total_energy_cost.toFixed(2) || '0.00'}` },
{ icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost?.toFixed(2) ?? '0.00'}` },
{ icon: Zap, color: 'text-yellow-400', label: t('stats.energyUsed'), value: `${stats?.total_energy_kwh?.toFixed(3) ?? '0.000'} kWh` },
{ icon: DollarSign, color: 'text-yellow-500', label: t('stats.energyCost'), value: `${currency} ${stats?.total_energy_cost?.toFixed(2) ?? '0.00'}` },
];
return (
@@ -350,15 +350,137 @@ function TimeAccuracyWidget({
);
}
function HourlyHeatmap({ printDates, dateFrom, dateTo }: { printDates: string[]; dateFrom: string; dateTo: string }) {
const { days, hourlyCounts, maxCount } = useMemo(() => {
const start = new Date(dateFrom + 'T00:00:00');
const end = new Date(dateTo + 'T00:00:00');
const days: { key: string; label: string }[] = [];
const fmtLocal = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const current = new Date(start);
while (current <= end) {
days.push({
key: fmtLocal(current),
label: current.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }),
});
current.setDate(current.getDate() + 1);
}
// Count prints per (day, hour)
const counts: Record<string, number> = {};
let max = 0;
printDates.forEach(d => {
const date = parseUTCDate(d);
if (!date) return;
const dayKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const k = `${dayKey}-${date.getHours()}`;
counts[k] = (counts[k] || 0) + 1;
if (counts[k] > max) max = counts[k];
});
return { days, hourlyCounts: counts, maxCount: Math.max(1, max) };
}, [printDates, dateFrom, dateTo]);
const getColor = (count: number) => {
if (count === 0) return 'bg-bambu-dark';
const intensity = count / maxCount;
if (intensity <= 0.25) return 'bg-bambu-green/30';
if (intensity <= 0.5) return 'bg-bambu-green/50';
if (intensity <= 0.75) return 'bg-bambu-green/75';
return 'bg-bambu-green';
};
const cellSize = 20;
const gap = 2;
const dayLabelWidth = 80;
return (
<div className="w-full overflow-x-auto">
<div className="inline-flex flex-col" style={{ gap }}>
{/* Hour labels row */}
<div className="flex" style={{ gap, marginLeft: dayLabelWidth + 4 }}>
{HOUR_LABELS.map((label, i) => (
<div
key={i}
className="text-bambu-gray text-[10px] text-center"
style={{ width: cellSize, visibility: i % 2 === 0 ? 'visible' : 'hidden' }}
>
{label}
</div>
))}
</div>
{/* Day rows */}
{days.map(day => (
<div key={day.key} className="flex items-center" style={{ gap }}>
<div
className="text-bambu-gray text-[10px] flex-shrink-0 truncate"
style={{ width: dayLabelWidth }}
>
{day.label}
</div>
{Array.from({ length: 24 }, (_, hour) => {
const count = hourlyCounts[`${day.key}-${hour}`] || 0;
return (
<div
key={hour}
className={`rounded-sm ${getColor(count)}`}
style={{ width: cellSize, height: cellSize }}
title={`${day.label} ${HOUR_LABELS[hour]}: ${count} print${count !== 1 ? 's' : ''}`}
/>
);
})}
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-3 text-bambu-gray text-xs">
<span>Less</span>
<div className="flex" style={{ gap }}>
<div className="rounded-sm bg-bambu-dark" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green/30" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green/50" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green/75" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green" style={{ width: cellSize, height: cellSize }} />
</div>
<span>More</span>
</div>
</div>
);
}
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 <HourlyHeatmap printDates={printDates} dateFrom={dateFrom} dateTo={dateTo} />;
}
// 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 <PrintCalendar printDates={printDates} months={months} />;
}
@@ -456,7 +578,7 @@ function PrinterStatsWidget({
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.toISOString().split('T')[0]);
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) => ({
@@ -569,15 +691,19 @@ function PrinterStatsWidget({
function FilamentTrendsWidget({
archives,
currency,
dateFrom,
dateTo,
}: {
archives: Parameters<typeof FilamentTrends>[0]['archives'];
currency: string;
dateFrom?: string;
dateTo?: string;
}) {
const { t } = useTranslation();
if (!archives || archives.length === 0) {
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
}
return <FilamentTrends archives={archives} currency={currency} />;
return <FilamentTrends archives={archives} currency={currency} dateFrom={dateFrom} dateTo={dateTo} />;
}
function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: {
@@ -803,13 +929,23 @@ export function StatsPage() {
const [dashboardKey, setDashboardKey] = useState(0);
const [hiddenCount, setHiddenCount] = useState(0);
const [isRecalculating, setIsRecalculating] = useState(false);
const [timeframe, setTimeframe] = useState<TimeframeState>({
preset: 'all-time',
dateFrom: undefined,
dateTo: undefined,
const [timeframe, setTimeframe] = useState<TimeframeState>(() => {
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 };
@@ -939,7 +1075,7 @@ export function StatsPage() {
{
id: 'print-activity',
title: t('stats.printActivity'),
component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
component: (size) => <PrintActivityWidget printDates={printDates} size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
defaultSize: 2,
},
{
@@ -957,7 +1093,7 @@ export function StatsPage() {
{
id: 'filament-trends',
title: t('stats.filamentTrends'),
component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
component: <FilamentTrendsWidget archives={archives || []} currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
defaultSize: 4,
},
];