Ver Fonte

Fix: Print Activity heatmap buckets by local date, not UTC date (#1446)

  PrintCalendar.tsx had three instances of the same UTC-shortcut anti-pattern:

  1. Bucketing input dates via `date.split('T')[0]` — gives the UTC day
     while the cell tooltip rendered local via `toLocaleDateString`. Same
     data, two renderers, only one was tz-correct. Reporter on CDT (UTC-5)
     saw evening prints jump to "tomorrow's" cell.

  2. Per-cell lookup key built via `day.toISOString().split('T')[0]`. The
     `day` Date objects produced by the calendar-generation loop are
     local-tz (constructed via `new Date()` + `setDate`), so `toISOString`
     shifted them back to UTC before the lookup — would have re-broken the
     join even after the bucketing fix.

  3. "Today" ring comparison used `new Date().toISOString().split('T')[0]`
     too — at 23:00 local the ring would have moved to UTC-tomorrow's cell.

  Fix adds a `localDateKey(input: string | Date): string` helper to
  utils/date.ts that wraps parseUTCDate and formats via the local-tz
  getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding),
  returning a stable comparable YYYY-MM-DD. PrintCalendar.tsx uses it in
  all three spots so the buckets, the cell join, and the today ring share
  the same local-tz axis as the user's tooltip label.

  Backend stays UTC. Bucketing is a presentation concern and the browser
  already knows the user's tz.

  Stragglers flagged for follow-up: StatsPage.computeDateRange builds
  dateFrom/dateTo for backend stats queries using getUTC* getters, so a
  "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based
  ranges to the backend. Fixing it properly also needs the backend to
  filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting
  model today. localDateKey is in place for reuse when that work lands.
maziggy há 1 semana atrás
pai
commit
fcee1a6f7e

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
CHANGELOG.md


+ 50 - 0
frontend/src/__tests__/utils/date.test.ts

@@ -16,6 +16,7 @@ import {
   formatETA,
   formatDuration,
   formatRelativeTime,
+  localDateKey,
 } from '../../utils/date';
 
 describe('getDatePlaceholder', () => {
@@ -436,3 +437,52 @@ describe('formatRelativeTime', () => {
     expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');
   });
 });
+
+describe('localDateKey (#1446 — Print Activity heatmap bucketing)', () => {
+  // We can't change Node's process.env.TZ at runtime (it's resolved once),
+  // but localDateKey's whole point is to use the *local* tz getters on the
+  // Date. So we feed it a Date object whose local components we control via
+  // the Date constructor's local-time form — that's equivalent to "the user
+  // is in tz X and the UTC timestamp lands on day Y locally."
+
+  it('keys a Date by its local-tz date, not UTC', () => {
+    // A Date whose local-tz representation is May 17, 2026 at 22:00. In CDT
+    // (UTC-5) the UTC representation would be May 18 03:00 — but the bucket
+    // key must follow the local view, which is what the heatmap renders.
+    const localEvening = new Date(2026, 4, 17, 22, 0, 0); // months are 0-indexed
+    expect(localDateKey(localEvening)).toBe('2026-05-17');
+  });
+
+  it('reproduces the #1446 repro: a UTC string that is "tomorrow UTC" but "today local" keys to today local', () => {
+    // Reporter's row 30: stored as 2026-05-18 03:39:07 UTC, local was
+    // 22:39 CDT May 17. The heatmap key must be 2026-05-17 in any local tz
+    // whose offset puts that UTC moment back into May 17. We construct the
+    // equivalent moment via a local-time Date object so the test is
+    // independent of which tz the CI runner is in.
+    const localMoment = new Date(2026, 4, 17, 22, 39, 7);
+    expect(localDateKey(localMoment)).toBe('2026-05-17');
+  });
+
+  it('returns an empty string for null / undefined / empty input', () => {
+    expect(localDateKey('')).toBe('');
+    // null and undefined typed via the string overload are still defensively
+    // handled by parseUTCDate, which returns null and we shortcut to ''.
+    expect(localDateKey(null as unknown as string)).toBe('');
+    expect(localDateKey(undefined as unknown as string)).toBe('');
+  });
+
+  it('pads single-digit month and day to two digits', () => {
+    const jan3 = new Date(2026, 0, 3, 10, 0, 0);
+    expect(localDateKey(jan3)).toBe('2026-01-03');
+  });
+
+  it('accepts an ISO string and parses it via parseUTCDate', () => {
+    // parseUTCDate treats naked ISO as UTC and converts to local. Verify
+    // the chain works end-to-end — a UTC-anchored input becomes a local
+    // YYYY-MM-DD bucket key.
+    const key = localDateKey('2026-05-17T16:27:23');
+    // The exact local date depends on the CI runner's tz, but the result
+    // must always be a well-formed YYYY-MM-DD (10 chars, two dashes).
+    expect(key).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+  });
+});

+ 13 - 5
frontend/src/components/PrintCalendar.tsx

@@ -1,5 +1,7 @@
 import { useMemo, useRef, useState, useEffect } from 'react';
 
+import { localDateKey } from '../utils/date';
+
 interface PrintCalendarProps {
   printDates: string[]; // Array of ISO date strings
   months?: number; // How many months to show (default 3)
@@ -24,11 +26,14 @@ export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
   }, []);
 
   const { weeks, monthLabels, printCounts } = useMemo(() => {
-    // Count prints per day
+    // Count prints per day. Bucket by local-tz YYYY-MM-DD so an evening
+    // print in a negative-UTC-offset region (e.g. CDT) doesn't get
+    // attributed to "tomorrow's" UTC cell while its label renders as
+    // today (#1446).
     const counts: Record<string, number> = {};
     printDates.forEach((date) => {
-      const day = date.split('T')[0];
-      counts[day] = (counts[day] || 0) + 1;
+      const day = localDateKey(date);
+      if (day) counts[day] = (counts[day] || 0) + 1;
     });
 
     // Generate weeks for the last N months
@@ -149,9 +154,12 @@ export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
                     );
                   }
 
-                  const dateStr = day.toISOString().split('T')[0];
+                  // Keep lookup + "today" comparison on the same local-tz
+                  // axis as the buckets — toISOString() would shift these
+                  // cells to UTC and break the join (#1446).
+                  const dateStr = localDateKey(day);
                   const count = printCounts[dateStr] || 0;
-                  const isToday = dateStr === new Date().toISOString().split('T')[0];
+                  const isToday = dateStr === localDateKey(new Date());
 
                   return (
                     <div

+ 20 - 0
frontend/src/utils/date.ts

@@ -443,3 +443,23 @@ export function formatDurationFromHours(hours: number): string {
   const m = Math.round((hours - h) * 60);
   return m > 0 ? `${h}h ${m}m` : `${h}h`;
 }
+
+/**
+ * Build a YYYY-MM-DD key for a date, evaluated in the user's local timezone.
+ *
+ * The naive `iso.split('T')[0]` shortcut gives the UTC date, which buckets
+ * an evening print in a negative-UTC-offset region (e.g. CDT) onto the
+ * following day. Stats / heatmap bucketing is a presentation concern and
+ * the browser knows the user's tz, so we format with the local getters
+ * here. Use `toLocaleDateString` for *displaying* a date — this helper is
+ * for *keying* buckets, where the value needs to be a stable comparable
+ * string regardless of locale conventions.
+ */
+export function localDateKey(input: string | Date): string {
+  const date = typeof input === 'string' ? parseUTCDate(input) : input;
+  if (!date) return '';
+  const y = date.getFullYear();
+  const m = String(date.getMonth() + 1).padStart(2, '0');
+  const d = String(date.getDate()).padStart(2, '0');
+  return `${y}-${m}-${d}`;
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-BrIgWKU8.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-HpdkPzCi.js"></script>
+    <script type="module" crossorigin src="/assets/index-BrIgWKU8.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   <body>

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff