diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 7eec7a959..cc4a0583b 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,7 +1,12 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { heatmapDays } from "@/lib/charts/heatmap-days"; interface DailyData { expected: number; @@ -79,6 +84,7 @@ export function ComplianceHeatmap({ }: ComplianceHeatmapProps) { const { t } = useTranslations(); const fmt = useFormatters(); + const timeZone = useDisplayTimezone(); // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the // locale-aware renderer never lands a day off in either direction. const formatDay = (dateKey: string) => @@ -175,7 +181,6 @@ export function ComplianceHeatmap({ t("charts.months.nov"), t("charts.months.dec"), ]; - const now = new Date(); const cellList: Array<{ dateKey: string; col: number; @@ -184,34 +189,18 @@ export function ComplianceHeatmap({ data: DailyData; }> = []; - // Build array of dates from oldest to newest - const dates: Date[] = []; - for (let d = days - 1; d >= 0; d--) { - const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); - dates.push(date); - } - - // Start from the first date, align to Monday. - // - // v1.4.27 B7 / BL-P4-2 — read every weekday + month boundary off - // the UTC accessor pair (`getUTCDay`, `getUTCMonth`) so the - // computation matches the dateKey, which is also UTC-anchored - // (`toISOString().slice(0, 10)`). Reading in server-tz here would - // shift the Monday-alignment + month-marker placement when the - // SSR pass runs on a server in a non-Berlin timezone. - const firstDate = dates[0]; - const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0 + // Oldest to newest, one entry per local day in the user's zone. + const dates = heatmapDays(new Date(), timeZone, days); + const firstDow = dates[0]?.dow ?? 0; let col = 0; const markers: Array<{ col: number; label: string }> = []; let lastMonth = -1; for (let i = 0; i < dates.length; i++) { - const date = dates[i]; - const dow = (date.getUTCDay() + 6) % 7; // Monday = 0 + const { dateKey, dow, month } = dates[i]; const currentCol = Math.floor((i + firstDow) / 7); const row = dow; - const dateKey = date.toISOString().slice(0, 10); const data = dailyCompliance[dateKey] ?? { expected: 0, taken: 0, @@ -219,7 +208,6 @@ export function ComplianceHeatmap({ }; // Track month boundaries - const month = date.getUTCMonth(); if (month !== lastMonth) { markers.push({ col: currentCol, label: MONTH_LABELS[month] }); lastMonth = month; @@ -237,7 +225,7 @@ export function ComplianceHeatmap({ } return { cells: cellList, weeks: col + 1, monthMarkers: markers }; - }, [dailyCompliance, days, t]); + }, [dailyCompliance, days, t, timeZone]); const activeDays = cells.filter((cell) => cell.data.expected > 0).length; const summaryLabel = diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 178064908..959d25d7e 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -1,7 +1,12 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { heatmapDays } from "@/lib/charts/heatmap-days"; import { moodLabelKeyForScore } from "@/lib/mood/labels"; /** @@ -62,6 +67,7 @@ export function MoodHeatmap({ }: MoodHeatmapProps) { const { t } = useTranslations(); const fmt = useFormatters(); + const timeZone = useDisplayTimezone(); // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the // locale-aware renderer never lands a day off in either direction. const formatDay = (dateKey: string) => @@ -147,7 +153,6 @@ export function MoodHeatmap({ t("charts.months.nov"), t("charts.months.dec"), ]; - const now = new Date(); const cellList: Array<{ dateKey: string; col: number; @@ -156,30 +161,19 @@ export function MoodHeatmap({ cell: MoodHeatmapCell | null; }> = []; - const dates: Date[] = []; - for (let d = days - 1; d >= 0; d--) { - const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); - dates.push(date); - } - - // Monday-align + month markers read off the UTC accessors so the - // grid matches the UTC-anchored dateKey (mirrors compliance-heatmap). - const firstDate = dates[0]; - const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0 + const dates = heatmapDays(new Date(), timeZone, days); + const firstDow = dates[0]?.dow ?? 0; let col = 0; const markers: Array<{ col: number; label: string }> = []; let lastMonth = -1; for (let i = 0; i < dates.length; i++) { - const date = dates[i]; - const dow = (date.getUTCDay() + 6) % 7; // Monday = 0 + const { dateKey, dow, month } = dates[i]; const currentCol = Math.floor((i + firstDow) / 7); const row = dow; - const dateKey = date.toISOString().slice(0, 10); const cell = cellData[dateKey] ?? null; - const month = date.getUTCMonth(); if (month !== lastMonth) { markers.push({ col: currentCol, label: MONTH_LABELS[month] }); lastMonth = month; @@ -197,7 +191,7 @@ export function MoodHeatmap({ } return { cells: cellList, weeks: col + 1, monthMarkers: markers }; - }, [cellData, days, t]); + }, [cellData, days, t, timeZone]); const loggedDays = cells.filter((cell) => cell.cell !== null).length; const summaryLabel = diff --git a/src/lib/charts/__tests__/heatmap-days.test.ts b/src/lib/charts/__tests__/heatmap-days.test.ts new file mode 100644 index 000000000..dbc0c973b --- /dev/null +++ b/src/lib/charts/__tests__/heatmap-days.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { heatmapDays } from "../heatmap-days"; + +describe("heatmapDays", () => { + it.each([ + // 20:30 in New York is already the next UTC day. + ["America/New_York", "2026-03-09T00:30:00.000Z", "2026-03-08"], + // 08:00 in Auckland and Tonga is still the previous UTC day. + ["Pacific/Auckland", "2026-04-04T20:00:00.000Z", "2026-04-05"], + ["Pacific/Tongatapu", "2026-07-27T19:00:00.000Z", "2026-07-28"], + ["America/Santiago", "2026-09-06T03:30:00.000Z", "2026-09-05"], + ["Europe/Berlin", "2026-10-25T22:30:00.000Z", "2026-10-25"], + ])("ends on today's key in %s", (tz, iso, today) => { + const days = heatmapDays(new Date(iso), tz, 14); + expect(days).toHaveLength(14); + expect(days.at(-1)?.dateKey).toBe(today); + // Consecutive keys, one per day, no gap or repeat across DST. + for (let i = 1; i < days.length; i++) { + const prev = Date.parse(`${days[i - 1].dateKey}T00:00:00Z`); + const cur = Date.parse(`${days[i].dateKey}T00:00:00Z`); + expect(cur - prev).toBe(86_400_000); + } + }); + + it("reads weekday and month off the key", () => { + const [last] = heatmapDays( + new Date("2026-03-01T03:00:00.000Z"), + "America/New_York", + 1, + ); + // 22:00 on Saturday 28 February in New York. + expect(last).toEqual({ dateKey: "2026-02-28", dow: 5, month: 1 }); + }); +}); diff --git a/src/lib/charts/heatmap-days.ts b/src/lib/charts/heatmap-days.ts new file mode 100644 index 000000000..f769f767a --- /dev/null +++ b/src/lib/charts/heatmap-days.ts @@ -0,0 +1,36 @@ +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +/** One calendar-heatmap column entry: a local day and where it sits. */ +export interface HeatmapDay { + /** `YYYY-MM-DD` in the user's zone. */ + dateKey: string; + /** Weekday with Monday = 0 … Sunday = 6. */ + dow: number; + /** Month index 0–11. */ + month: number; +} + +/** + * The last `days` local days in `timeZone`, oldest first, ending with today. + * + * Built from today's key in the user's zone and stepped on keys. The heatmaps + * used to take `now − n·24 h` and cut the key in UTC, so in the evening west + * of UTC (or the morning east of it) "today" was a different day from the one + * the server had keyed the user's readings on, and the newest cell showed the + * wrong day. Weekday and month come from the key itself. + */ +export function heatmapDays( + now: Date, + timeZone: string, + days: number, +): HeatmapDay[] { + const today = userDayKey(now, timeZone); + const out: HeatmapDay[] = []; + for (let back = days - 1; back >= 0; back--) { + const dateKey = shiftDateKey(today, -back); + const [y, m, d] = dateKey.split("-").map(Number); + const utc = new Date(Date.UTC(y, m - 1, d)); + out.push({ dateKey, dow: (utc.getUTCDay() + 6) % 7, month: m - 1 }); + } + return out; +}