fix(charts): draw bucket boundaries in the profile's calendar, not Berlin's

The bucketer hard-coded Europe/Berlin for day, week and month boundaries, so
a reading taken on the last evening of a month landed in the wrong month's
bar for everyone who does not live there. Two smaller siblings did the same
thing: the Today chip asked the browser for a time instead of the profile,
and the score card's tooltip formatter carried a locale but no timezone.

The boundary timezone is a required option rather than a defaulted one. A
default would have left any caller that forgets to pass one still wrong and
still silent; required makes the compiler name them, which is how the three
call sites and the eight test sites in this diff were found rather than
guessed at. The tests were always asserting Berlin's calendar without saying
so, and now they say so.

Two fixture mistakes worth recording, both caught by running the tests rather
than trusting them: summer time is already in force in Berlin on 31 March, so
the first instant chosen was April on both sides; and 31 March 2025 falls in
the same ISO week as 1 April, so the week case needed its own instant where
one side is still Sunday.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 23:18:12 +02:00
1 parent 87877b285f
commit f1773cf602
8 files changed
+183 -51

No files matched your search

@@ -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: [],
});
});
});
+2 -2
View File
@@ -1130,7 +1130,7 @@ export function HealthChart({
types.map((type) => [type, p[type] as number | undefined]),
),
})),
{ bucket: bucketType },
{ bucket: bucketType, timeZone: userTimezone },
).points.map<ChartDataPoint>((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.
//
+20 -4
View File
@@ -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.
+7 -6
View File
@@ -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;
}
@@ -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],
);