mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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(',')}`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user