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

@@ -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 {