Browse Source

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.
AneoPsy 2 months ago
parent
commit
ff3d3a82a7

+ 1 - 1
backend/app/api/routes/archives.py

@@ -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),

+ 2 - 2
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -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
frontend/src/api/client.ts

@@ -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
frontend/src/components/FilamentTrends.tsx

@@ -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
frontend/src/pages/StatsPage.tsx

@@ -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,
     },
   ];