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],
);
@@ -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));
@@ -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);
});
+52 -27
View File
@@ -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<string, Intl.DateTimeFormat>();
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 {