diff --git a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx index ee11cc628..4ff985d73 100644 --- a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx +++ b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx @@ -55,6 +55,10 @@ describe("pickMoodBucket()", () => { }); }); +// Berlin was the module's hard-coded calendar, so these expectations were +// always Berlin's. Naming it here keeps that visible now it is a parameter. +const BERLIN = "Europe/Berlin"; + describe("aggregateMoodEntries()", () => { it("returns daily points unchanged for short windows", () => { const entries = [ @@ -62,7 +66,7 @@ describe("aggregateMoodEntries()", () => { { date: "2026-05-02", score: 4 }, { date: "2026-05-03", score: 5 }, ]; - const out = aggregateMoodEntries(entries); + const out = aggregateMoodEntries(entries, BERLIN); expect(out.bucket).toBe("day"); expect(out.points).toHaveLength(3); expect(out.points.map((p) => p.score)).toEqual([3, 4, 5]); @@ -71,7 +75,7 @@ describe("aggregateMoodEntries()", () => { it("aggregates to weekly mean for windows in the 91-730d range", () => { // 100-day span — pickBucket → "week". const entries = dayEntries("2026-01-01", 100, 4); - const out = aggregateMoodEntries(entries); + const out = aggregateMoodEntries(entries, BERLIN); expect(out.bucket).toBe("week"); // Constant input score = mean of every weekly bucket should be the // same constant (independent of partial-week edge effects). @@ -85,7 +89,7 @@ describe("aggregateMoodEntries()", () => { it("aggregates to monthly mean for windows > 730 days", () => { const entries = dayEntries("2024-01-01", 800, 5); - const out = aggregateMoodEntries(entries); + const out = aggregateMoodEntries(entries, BERLIN); expect(out.bucket).toBe("month"); // 800 days ≈ 27 months — well below the daily count. expect(out.points.length).toBeLessThan(50); @@ -97,7 +101,10 @@ describe("aggregateMoodEntries()", () => { }); it("emits empty points array for empty input", () => { - expect(aggregateMoodEntries([])).toEqual({ bucket: "day", points: [] }); + expect(aggregateMoodEntries([], BERLIN)).toEqual({ + bucket: "day", + points: [], + }); }); }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 563b39fba..26958e593 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1130,7 +1130,7 @@ export function HealthChart({ types.map((type) => [type, p[type] as number | undefined]), ), })), - { bucket: bucketType }, + { bucket: bucketType, timeZone: userTimezone }, ).points.map((point) => { const date = new Date(point.timestamp); const out: ChartDataPoint = { @@ -1199,7 +1199,7 @@ export function HealthChart({ } return enriched; - }, [data, visibleSlice, showMA, showTrend, types, tzFmt]); + }, [data, visibleSlice, showMA, showTrend, types, tzFmt, userTimezone]); // v1.12.8 — chart-reactive metric statistics. // diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index f8ab01112..df82bcc4f 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -20,6 +20,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { useTranslations } from "@/lib/i18n/context"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; +import { readStoredTimezone } from "@/lib/timezone-mirror"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import type { DataSummary } from "@/lib/analytics/trends"; import { bucketTimeSeries, @@ -244,7 +246,16 @@ export function pickMoodBucket(entries: MoodEntryDay[]): ChartBucketType { * Pure & deterministic so the unit test can pin the exact aggregated * mean per bucket. */ -export function aggregateMoodEntries(entries: MoodEntryDay[]): { +export function aggregateMoodEntries( + entries: MoodEntryDay[], + /** + * The calendar the week / month boundaries are drawn in. Required rather + * than defaulted for the same reason `bucketTimeSeries` requires it: a + * default would leave a forgotten caller quietly bucketing someone else's + * evening into the wrong month. + */ + timeZone: string, +): { bucket: ChartBucketType; points: Array<{ timestamp: number; score: number }>; } { @@ -268,7 +279,7 @@ export function aggregateMoodEntries(entries: MoodEntryDay[]): { timestamp: dayKeyToTimestamp(entry.date), values: { score: entry.score }, })), - { bucket }, + { bucket, timeZone }, ); return { @@ -309,6 +320,11 @@ export function MoodChart({ // encoded day paints verbatim in every profile zone (a profile-tz // formatter shifted week/month bucket labels for zones west of Berlin). const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]); + // The bucket BOUNDARIES follow the profile, even though the bucket LABELS + // stay UTC-pinned (see above): a week that starts on the wrong day puts a + // reading in the wrong bar, which is a different mistake from a label that + // reads a few hours off. + const profileTimeZone = readStoredTimezone() || DEFAULT_TIMEZONE; const initialRangePoints = windowOverride ? MINI_RANGE_POINTS[windowOverride] : 30; @@ -453,7 +469,7 @@ export function MoodChart({ timestamp: p.timestamp, values: { score: p.score }, })), - { bucket: bucketType }, + { bucket: bucketType, timeZone: profileTimeZone }, ).points.map((point) => ({ date: tzFmt.dateShortSmart(new Date(point.timestamp)), timestamp: point.timestamp, @@ -507,7 +523,7 @@ export function MoodChart({ } return enriched; - }, [data, rangePoints, showMA, showTrend, tzFmt]); + }, [data, rangePoints, showMA, showTrend, tzFmt, profileTimeZone]); /** * v1.4.16 B8 — comparison overlay merged into chartData. diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 38431625e..ae9806668 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -38,6 +38,7 @@ import Link from "next/link"; import { Moon } from "lucide-react"; import { useMounted } from "@/hooks/use-mounted"; +import { formatTime } from "@/lib/format"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; @@ -73,17 +74,17 @@ function formatDelta( * the hydration render — React #418, a class of bug this project has already * paid for once. * - * `undefined` locale lets the browser pick the user's own regional format - * (24-hour here, 12-hour there); the chip is a timestamp, not copy. + * Goes through the shared `formatTime`, which reads the profile's timezone and + * clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces + * asked the BROWSER instead, which is a different question: someone whose + * profile says Europe/Berlin while their laptop sits in Manila saw a Manila + * time here and a Berlin time on every other surface, for the same instant. */ function formatJustInTime(iso: string): string | null { const at = new Date(iso); if (Number.isNaN(at.getTime())) return null; try { - return at.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - }); + return formatTime(at); } catch { return null; } diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 796ce76c1..3c8e41dca 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -11,6 +11,7 @@ import { Scale, } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { readStoredTimezone } from "@/lib/timezone-mirror"; import { cn } from "@/lib/utils"; import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; @@ -219,12 +220,21 @@ export function HealthScoreCard({ // we fall through to the raw ISO so the user still sees something. // The formatter is memoised per locale so the row map below doesn't // build a fresh `Intl.DateTimeFormat` instance per row. + // The timezone is as load-bearing as the locale and was missing: without it + // the formatter used the browser's, so a reading taken late in the evening + // could print yesterday's date for someone whose profile timezone is east of + // where their laptop happens to be. The locale still decides the separator + // convention; the timezone decides which day it was. const asOfFormatter = useMemo( () => new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "2-digit", + // `readStoredTimezone` returns "" when nothing is stored or during + // SSR, and an empty string makes Intl throw RangeError rather than + // fall back. `undefined` is the value that means "use the default". + timeZone: readStoredTimezone() || undefined, }), [locale], ); diff --git a/src/lib/charts/__tests__/bucket-label.test.ts b/src/lib/charts/__tests__/bucket-label.test.ts index a79cba792..de7fc42a5 100644 --- a/src/lib/charts/__tests__/bucket-label.test.ts +++ b/src/lib/charts/__tests__/bucket-label.test.ts @@ -16,6 +16,9 @@ import { bucketTimeSeries } from "../bucket-time-series"; import { makeBucketLabelFormatters } from "../bucket-label"; import { makeFormatters } from "@/lib/format-locale"; +// Berlin was the module's hard-coded calendar, so these expectations were +// always Berlin's. Naming it here keeps that visible now it is a parameter. +const BERLIN = "Europe/Berlin"; describe("makeBucketLabelFormatters (#490)", () => { // Two July instants (Berlin summer) forced into a month bucket. const monthBucket = bucketTimeSeries( @@ -23,7 +26,7 @@ describe("makeBucketLabelFormatters (#490)", () => { { timestamp: new Date("2026-07-05T10:00:00Z"), values: { WEIGHT: 80 } }, { timestamp: new Date("2026-07-20T10:00:00Z"), values: { WEIGHT: 82 } }, ], - { bucket: "month" }, + { bucket: "month", timeZone: BERLIN }, ); it("labels a Berlin July month bucket as July for every profile", () => { @@ -65,7 +68,7 @@ describe("makeBucketLabelFormatters (#490)", () => { values: { WEIGHT: 80 }, }, ], - { bucket: "week" }, + { bucket: "week", timeZone: BERLIN }, ); const ts = weekBucket.points[0].timestamp; expect(ts).toBe(Date.UTC(2026, 6, 13)); diff --git a/src/lib/charts/__tests__/bucket-time-series.test.ts b/src/lib/charts/__tests__/bucket-time-series.test.ts index 9ed8a0761..aa8985832 100644 --- a/src/lib/charts/__tests__/bucket-time-series.test.ts +++ b/src/lib/charts/__tests__/bucket-time-series.test.ts @@ -30,12 +30,73 @@ describe("pickBucket()", () => { }); }); +// These expectations were always Berlin's — the module used to hard-code it, +// so the tests read as timezone-agnostic while quietly depending on one. Now +// that the boundary calendar is a parameter, the assumption is written down +// rather than implied. +const BERLIN = "Europe/Berlin"; + +describe("the boundary calendar is the caller's, not Berlin's", () => { + // 2025-03-31T16:00Z is 18:00 on 31 March in Berlin (UTC+2, summer time is + // already in force) and 01:00 on 1 April in Tokyo (UTC+9). Which month bar + // this reading lands in is decided by the calendar the caller names, and + // until now the caller could not name one: every chart bucketed everyone's + // readings into Berlin's months. + const lateEvening = new Date("2025-03-31T16:00:00Z"); + const points = [{ timestamp: lateEvening, values: { v: 1 } }]; + + it("puts a late-evening reading in March for Berlin", () => { + const out = bucketTimeSeries(points, { + bucket: "month", + timeZone: "Europe/Berlin", + }); + expect(new Date(out.points[0].timestamp).toISOString()).toBe( + "2025-03-01T00:00:00.000Z", + ); + }); + + it("puts the same instant in April for Tokyo", () => { + const out = bucketTimeSeries(points, { + bucket: "month", + timeZone: "Asia/Tokyo", + }); + expect(new Date(out.points[0].timestamp).toISOString()).toBe( + "2025-04-01T00:00:00.000Z", + ); + }); + + it("moves the ISO week too, not only the month", () => { + // A separate instant, because the month fixture above does not serve + // here: 31 March 2025 is a Monday in Berlin and 1 April a Tuesday in + // Tokyo, which is the SAME ISO week. The week boundary only moves when + // one side is still Sunday while the other is already Monday. + // 2025-03-30T16:00Z is Sunday 18:00 in Berlin and Monday 01:00 in Tokyo. + const sundayEvening = [ + { timestamp: new Date("2025-03-30T16:00:00Z"), values: { v: 1 } }, + ]; + const berlin = bucketTimeSeries(sundayEvening, { + bucket: "week", + timeZone: "Europe/Berlin", + }); + const tokyo = bucketTimeSeries(sundayEvening, { + bucket: "week", + timeZone: "Asia/Tokyo", + }); + expect(new Date(berlin.points[0].timestamp).toISOString()).toBe( + "2025-03-24T00:00:00.000Z", + ); + expect(new Date(tokyo.points[0].timestamp).toISOString()).toBe( + "2025-03-31T00:00:00.000Z", + ); + }); +}); + describe("bucketTimeSeries()", () => { const start = new Date("2025-01-06T00:00:00Z"); // Monday it("preserves daily granularity for ranges <= 90 days", () => { const points = [0, 1, 2, 30, 89].map((d) => dailyAt(start, d, 80 + d)); - const result = bucketTimeSeries(points); + const result = bucketTimeSeries(points, { timeZone: BERLIN }); expect(result.bucket).toBe("day"); expect(result.points).toHaveLength(5); }); @@ -45,7 +106,7 @@ describe("bucketTimeSeries()", () => { const points = Array.from({ length: 200 }, (_, i) => dailyAt(start, i, 80 + (i % 5)), ); - const result = bucketTimeSeries(points); + const result = bucketTimeSeries(points, { timeZone: BERLIN }); expect(result.bucket).toBe("week"); // 200 / 7 ≈ 28.5 — first/last weeks may be partial → 29 distinct keys. expect(result.points.length).toBeGreaterThanOrEqual(28); @@ -61,7 +122,7 @@ describe("bucketTimeSeries()", () => { const points = Array.from({ length: 800 }, (_, i) => dailyAt(start, i, 100), ); - const result = bucketTimeSeries(points); + const result = bucketTimeSeries(points, { timeZone: BERLIN }); expect(result.bucket).toBe("month"); // ~26 calendar months expect(result.points.length).toBeGreaterThanOrEqual(25); @@ -79,7 +140,10 @@ describe("bucketTimeSeries()", () => { { timestamp: start.getTime(), values: { weight: 80 } }, { timestamp: start.getTime() + 200 * dayMs, values: { weight: 90 } }, ]; - const result = bucketTimeSeries(points, { bucket: "week" }); + const result = bucketTimeSeries(points, { + bucket: "week", + timeZone: BERLIN, + }); expect(result.points).toHaveLength(2); expect(result.points.map((p) => p.values.weight)).toEqual([80, 90]); }); @@ -90,7 +154,10 @@ describe("bucketTimeSeries()", () => { { timestamp: start.getTime() + dayMs, values: { sys: 140 } }, // dia missing { timestamp: start.getTime() + 2 * dayMs, values: { dia: 78 } }, // sys missing ]; - const result = bucketTimeSeries(points, { bucket: "week" }); + const result = bucketTimeSeries(points, { + bucket: "week", + timeZone: BERLIN, + }); expect(result.points).toHaveLength(1); const point = result.points[0]; expect(point.counts.sys).toBe(2); @@ -101,7 +168,10 @@ describe("bucketTimeSeries()", () => { it("respects the explicit bucket override", () => { const points = [0, 1, 2].map((d) => dailyAt(start, d, 80)); - const result = bucketTimeSeries(points, { bucket: "month" }); + const result = bucketTimeSeries(points, { + bucket: "month", + timeZone: BERLIN, + }); expect(result.bucket).toBe("month"); expect(result.points).toHaveLength(1); }); diff --git a/src/lib/charts/bucket-time-series.ts b/src/lib/charts/bucket-time-series.ts index 437074a7c..f4ca0b200 100644 --- a/src/lib/charts/bucket-time-series.ts +++ b/src/lib/charts/bucket-time-series.ts @@ -35,35 +35,45 @@ export interface BucketedChartSeries { points: ChartBucketPoint[]; } -const BERLIN_TZ = "Europe/Berlin"; - -const BERLIN_FMT = new Intl.DateTimeFormat("en-US", { - timeZone: BERLIN_TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", -}); - const MS_PER_DAY = 24 * 60 * 60 * 1000; -interface BerlinYmd { +/** + * One formatter per timezone. Building an `Intl.DateTimeFormat` is expensive + * enough that doing it per point matters on a two-year monthly chart. + */ +const ymdFormatters = new Map(); + +function ymdFormatter(timeZone: string): Intl.DateTimeFormat { + const cached = ymdFormatters.get(timeZone); + if (cached) return cached; + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + ymdFormatters.set(timeZone, fmt); + return fmt; +} + +interface ZonedYmd { year: number; month: number; day: number; } -function toBerlinYmd(date: Date): BerlinYmd { - const parts = BERLIN_FMT.formatToParts(date); +function toZonedYmd(date: Date, timeZone: string): ZonedYmd { + const parts = ymdFormatter(timeZone).formatToParts(date); const year = Number(parts.find((p) => p.type === "year")?.value); const month = Number(parts.find((p) => p.type === "month")?.value); const day = Number(parts.find((p) => p.type === "day")?.value); if (!year || !month || !day) { - throw new Error("Could not derive Berlin Y-M-D"); + throw new Error(`Could not derive Y-M-D in ${timeZone}`); } return { year, month, day }; } -function utcMidnight({ year, month, day }: BerlinYmd): number { +function utcMidnight({ year, month, day }: ZonedYmd): number { return Date.UTC(year, month - 1, day); } @@ -72,21 +82,21 @@ function utcMidnight({ year, month, day }: BerlinYmd): number { * containing the first Thursday of that year. Returns the UTC * midnight of the Monday of the week containing `date`. */ -function isoWeekStartUtc(date: Date): number { - const ymd = toBerlinYmd(date); +function isoWeekStartUtc(date: Date, timeZone: string): number { + const ymd = toZonedYmd(date, timeZone); const utc = utcMidnight(ymd); // Day of week, 1=Mon..7=Sun (ISO). const dow = ((new Date(utc).getUTCDay() + 6) % 7) + 1; return utc - (dow - 1) * MS_PER_DAY; } -function monthStartUtc(date: Date): number { - const { year, month } = toBerlinYmd(date); +function monthStartUtc(date: Date, timeZone: string): number { + const { year, month } = toZonedYmd(date, timeZone); return Date.UTC(year, month - 1, 1); } -function dayStartUtc(date: Date): number { - return utcMidnight(toBerlinYmd(date)); +function dayStartUtc(date: Date, timeZone: string): number { + return utcMidnight(toZonedYmd(date, timeZone)); } export interface BucketInputPoint { @@ -103,6 +113,16 @@ export interface BucketInputPoint { export interface BucketTimeSeriesOptions { /** Override the bucketer when the caller already knows it. */ bucket?: ChartBucketType; + /** + * The calendar the day / week / month boundaries are drawn in. + * + * Deliberately required rather than defaulted. This module used to hard-code + * Europe/Berlin, which meant a reading late on the last evening of a month + * fell into the wrong month's bar for anyone who does not live there — and a + * default would have left every caller that forgets to pass one still wrong, + * silently. Required turns that into a compile error instead. + */ + timeZone: string; } /** @@ -116,14 +136,15 @@ export function pickBucket(rangeDays: number): ChartBucketType { /** * Bucket a daily-aggregated chart series into day/week/month groups. - * Weeks use ISO 8601 (Monday start); months use Berlin calendar. - * Empty buckets are skipped so a sparse user does not get a flat zero - * baseline. + * Weeks use ISO 8601 (Monday start); every boundary is drawn in the caller's + * `timeZone`. Empty buckets are skipped so a sparse user does not get a flat + * zero baseline. */ export function bucketTimeSeries( points: BucketInputPoint[], - options: BucketTimeSeriesOptions = {}, + options: BucketTimeSeriesOptions, ): BucketedChartSeries { + const { timeZone } = options; const sorted = [...points].sort( (a, b) => toMs(a.timestamp) - toMs(b.timestamp), ); @@ -144,14 +165,18 @@ export function bucketTimeSeries( // Day-granularity is the upstream's native shape, but we still go // through aggregation so the output shape is consistent and so // duplicate-day callers (raw API mode) collapse cleanly. - return aggregate(sorted, bucket, (ts) => dayStartUtc(new Date(toMs(ts)))); + return aggregate(sorted, bucket, (ts) => + dayStartUtc(new Date(toMs(ts)), timeZone), + ); } if (bucket === "week") { return aggregate(sorted, bucket, (ts) => - isoWeekStartUtc(new Date(toMs(ts))), + isoWeekStartUtc(new Date(toMs(ts)), timeZone), ); } - return aggregate(sorted, bucket, (ts) => monthStartUtc(new Date(toMs(ts)))); + return aggregate(sorted, bucket, (ts) => + monthStartUtc(new Date(toMs(ts)), timeZone), + ); } function toMs(ts: number | Date): number {