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.
This commit is contained in:
maziggy
2026-05-20 09:45:28 +02:00
parent 0406487eb3
commit fcee1a6f7e
6 changed files with 511 additions and 431 deletions
+20
View File
@@ -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}`;
}