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
+13 -5
View File
@@ -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