fix(insights): correct DST handling in cross-metric daily bucket pairing

The `pairDailyBuckets` helper in blood-pressure-status, weight-status,
and mood-status synthesised a `date` field via
`new Date(now − dayOffset·86_400_000)`. That subtraction treats every
day as exactly 24h — wrong on the Europe/Berlin DST boundaries (the
spring-forward day is 23h, the fall-back day is 25h). The result was
that `toBerlinDayKey(date)` slipped one calendar day for ~2 days/year,
causing miscredited correlations and mismatched same-day BP joins on
those boundaries.

Adds and exports `dayOffsetToBerlinDayKey(now, dayOffset)` from
`bucket-series.ts`. The helper anchors at Berlin Y-M-D (DST-immune via
Intl) then subtracts in UTC-anchored Berlin-day space, where every day
is exactly 86_400_000 ms wide because both endpoints are UTC midnights
of consecutive Berlin calendar days.

All three `pairDailyBuckets` helpers and the four other call sites
that synthesised a date from a dayOffset now use the helper. The
returned pair carries `dayKey` directly so callers don't have to
re-format. The synthesised `date` field is anchored at the UTC
midnight of the Berlin day so any code that still formats it via
`toBerlinDayKey()` gets the same DST-safe answer.

Adds unit tests across the 2024 spring (2024-03-31 02:00 → 03:00)
and autumn (2024-10-27 03:00 → 02:00) DST boundaries from late-evening
and early-morning Berlin times — the cases where the naive 24h
subtraction visibly slips a day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 16:02:26 +02:00
1 parent 512a6a619c
commit cb6a59a431
5 files changed
+162 -31

No files matched your search

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { applyPayloadBudget, bucketSeries } from "../bucket-series";
import {
applyPayloadBudget,
bucketSeries,
dayOffsetToBerlinDayKey,
} from "../bucket-series";
const dayMs = 24 * 60 * 60 * 1000;
@@ -71,6 +75,68 @@ describe("bucketSeries()", () => {
});
});
describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
// Europe/Berlin DST in 2024: spring-forward at 2024-03-31 02:00 → 03:00,
// fall-back at 2024-10-27 03:00 → 02:00. Naive `now − dayOffset·86_400_000`
// would crawl past those boundaries by an hour and silently land on the
// wrong calendar day for ~2 days/year. The helper must treat dayOffset
// as CALENDAR days, not 24-hour ticks.
it("produces today's Berlin date for dayOffset 0 around the spring-forward", () => {
// 2024-03-31 11:30 Berlin (= 09:30 UTC, after the gap).
const now = new Date("2024-03-31T09:30:00.000Z");
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-03-31");
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-03-30");
});
it("crossing the spring-forward DST boundary stays on the correct Berlin calendar day", () => {
// April 2 11:30 Berlin → 09:30 UTC (post-DST CEST).
const now = new Date("2024-04-02T09:30:00.000Z");
// Naive subtraction (now − 3·86_400_000) = 2024-03-30T09:30:00 UTC →
// 11:30 CET → still 2024-03-30. That happens to look right here, so
// the killer case is dayOffset 2 from a moment LATE on the day before
// the gap.
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-04-02");
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-04-01");
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-03-31");
expect(dayOffsetToBerlinDayKey(now, 3)).toBe("2024-03-30");
});
it("crossing the spring-forward boundary from late-evening Berlin time does not slip a day", () => {
// 2024-04-01 00:30 Berlin (= 2024-03-31 22:30 UTC, in CEST). Naive
// (now − 1·86_400_000) lands at 2024-03-30 22:30 UTC = 2024-03-30
// 23:30 CET, which Intl formats as 2024-03-30. The CALENDAR-day
// answer is 2024-03-31 — one Berlin calendar day earlier than today.
const now = new Date("2024-03-31T22:30:00.000Z");
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-04-01");
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-03-31");
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-03-30");
});
it("crossing the fall-back boundary from late-evening Berlin time does not slip a day", () => {
// 2024-10-28 00:30 Berlin (CET, post-fall-back) = 2024-10-27 23:30 UTC.
// Naive (now − 1·86_400_000) = 2024-10-26 23:30 UTC, which Intl
// formats with timeZone Europe/Berlin (still CEST!) as 2024-10-27
// 01:30 → 2024-10-27. That's actually correct by coincidence, so we
// probe the dayOffset where the bias bites:
const now = new Date("2024-10-27T23:30:00.000Z");
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-10-28");
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-10-27");
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-10-26");
});
it("crossing the fall-back boundary the other direction (early-morning) stays put", () => {
// 2024-10-27 02:30 Berlin AFTER the fall-back = 02:30 CET = 01:30 UTC.
// dayOffset 1 should be 2024-10-26 (calendar), not 2024-10-25 (which
// is what naive 24h subtraction gives because the day was 25 hours
// long).
const now = new Date("2024-10-27T01:30:00.000Z");
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-10-27");
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-10-26");
expect(dayOffsetToBerlinDayKey(now, 7)).toBe("2024-10-20");
});
});
describe("applyPayloadBudget()", () => {
const now = new Date("2026-05-09T12:00:00Z");
+26 -12
View File
@@ -19,12 +19,11 @@ import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks";
import {
applyPayloadBudget,
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { annotate } from "@/lib/logging/context";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
@@ -80,25 +79,39 @@ function summarizeSeries(series: Array<{ value: number }>) {
};
}
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
* field is anchored at the UTC midnight of the Berlin calendar day —
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
* don't slip the day-key by one. Each pair also carries `dayKey`
* directly so callers can label points without re-formatting.
*/
function pairDailyBuckets(
seriesA: DailyBucket[],
seriesB: DailyBucket[],
now: Date,
): PairedPoint[] {
): Array<PairedPoint & { dayKey: string }> {
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
const nowMs = now.getTime();
return seriesA
.map((entry) => {
const b = mapB.get(entry.dayOffset);
if (b == null) return null;
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset);
// UTC midnight of the Berlin day — formatting this Date with
// `toBerlinDayKey()` is guaranteed DST-safe because the y-m-d
// fields below are the Berlin calendar day fields by construction.
const [y, m, d] = dayKey.split("-").map(Number);
return {
a: entry.value,
b,
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
date: new Date(Date.UTC(y, m - 1, d)),
dayKey,
};
})
.filter((entry): entry is PairedPoint => entry !== null);
.filter(
(entry): entry is PairedPoint & { dayKey: string } => entry !== null,
);
}
export async function generateBloodPressureStatusForUser(
@@ -217,7 +230,7 @@ export async function generateBloodPressureStatusForUser(
diaSeries.daily,
now,
).map((entry) => ({
day: toBerlinDayKey(entry.date),
day: entry.dayKey,
sys: entry.a,
dia: entry.b,
inTarget:
@@ -322,10 +335,9 @@ export async function generateBloodPressureStatusForUser(
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
}
const nowMs = now.getTime();
const continuityVsSystolicSeries = sysSeries.daily.map((point) => {
const dayDate = new Date(nowMs - point.dayOffset * MS_PER_DAY);
const dayKey = toBerlinDayKey(dayDate);
// DST-safe: dayOffsetToBerlinDayKey computes calendar days, not 24h ticks.
const dayKey = dayOffsetToBerlinDayKey(now, point.dayOffset);
const taken = takenByDay.get(dayKey) ?? 0;
const continuityPct =
expectedBpIntakesPerDay > 0
@@ -361,10 +373,12 @@ export async function generateBloodPressureStatusForUser(
const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries
.map((entry) => {
if (entry.continuityPct == null) return null;
const [y, m, d] = entry.day.split("-").map(Number);
return {
a: entry.continuityPct,
b: entry.sys,
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
// UTC midnight of the Berlin day — DST-safe (see pairDailyBuckets).
date: new Date(Date.UTC(y, m - 1, d)),
};
})
.filter((entry): entry is PairedPoint => entry !== null);
@@ -432,7 +446,7 @@ export async function generateBloodPressureStatusForUser(
weightVsSystolic: {
correlation: weightVsSystolicCorrelation,
pairs: weightVsSystolicPairs.map((entry) => ({
day: toBerlinDayKey(entry.date),
day: entry.dayKey,
weight: round(entry.a, 2),
systolic: round(entry.b, 2),
})),
+32
View File
@@ -83,6 +83,38 @@ function utcMidnightOfBerlinDay(date: Date): number {
return Date.UTC(year, month - 1, day);
}
/**
* Convert a `dayOffset` (0 = today in Berlin, 1 = yesterday, …) back to
* the `YYYY-MM-DD` Berlin calendar day key the offset refers to.
*
* The naive `new Date(now − dayOffset·86_400_000)` then formatting in
* `Europe/Berlin` is OFF-BY-ONE-DAY across DST boundaries — the day of
* the spring-forward is 23h long and the day of the fall-back is 25h
* long, so a 24h subtraction crawls past the boundary by one hour and
* silently lands on the wrong calendar day for ~2 days/year.
*
* This helper anchors on Berlin Y-M-D first (DST-immune via Intl), then
* does the subtraction in UTC-of-Y-M-D space where every day is exactly
* 86_400_000 ms wide — so calendar arithmetic stays exact.
*
* Exported so the cross-metric `pairDailyBuckets` helpers in
* `blood-pressure-status.ts`, `weight-status.ts`, `mood-status.ts`,
* etc. all use the same source of truth.
*/
export function dayOffsetToBerlinDayKey(now: Date, dayOffset: number): string {
const todayMidnight = utcMidnightOfBerlinDay(now);
// Subtraction in UTC-anchored Berlin-day space: every day is 24h wide
// because both endpoints are UTC midnights of consecutive Berlin
// calendar days, regardless of how long the wall-clock day actually was.
const targetUtc = new Date(todayMidnight - dayOffset * MS_PER_DAY);
// Read the Y-M-D off the UTC fields directly — we anchored at UTC
// midnight, so `getUTC*` gives back the Berlin calendar day exactly.
const year = targetUtc.getUTCFullYear();
const month = String(targetUtc.getUTCMonth() + 1).padStart(2, "0");
const day = String(targetUtc.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
+16 -4
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/analytics/correlations";
import {
applyPayloadBudget,
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { annotate } from "@/lib/logging/context";
@@ -73,25 +74,36 @@ function summarizeSeries(series: Array<{ value: number }>) {
};
}
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
* field is anchored at the UTC midnight of the Berlin calendar day —
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
* don't slip the day-key by one. Each pair also carries `dayKey`
* directly so callers can label points without re-formatting.
*/
function pairDailyBuckets(
seriesA: DailyBucket[],
seriesB: DailyBucket[],
now: Date,
): PairedPoint[] {
): Array<PairedPoint & { dayKey: string }> {
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
const nowMs = now.getTime();
return seriesA
.map((entry) => {
const b = mapB.get(entry.dayOffset);
if (b == null) return null;
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset);
const [y, m, d] = dayKey.split("-").map(Number);
return {
a: entry.value,
b,
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
date: new Date(Date.UTC(y, m - 1, d)),
dayKey,
};
})
.filter((entry): entry is PairedPoint => entry !== null);
.filter(
(entry): entry is PairedPoint & { dayKey: string } => entry !== null,
);
}
export async function generateMoodStatusForUser(
+21 -14
View File
@@ -15,12 +15,11 @@ import {
} from "@/lib/insights/memory";
import {
applyPayloadBudget,
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { annotate } from "@/lib/logging/context";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
@@ -76,25 +75,36 @@ function summarizeSeries(series: Array<{ value: number }>) {
};
}
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
* field is anchored at the UTC midnight of the Berlin calendar day —
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
* don't slip the day-key by one. Each pair also carries `dayKey`
* directly so callers can label points without re-formatting.
*/
function pairDailyBuckets(
seriesA: DailyBucket[],
seriesB: DailyBucket[],
now: Date,
): PairedPoint[] {
): Array<PairedPoint & { dayKey: string }> {
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
const nowMs = now.getTime();
return seriesA
.map((entry) => {
const b = mapB.get(entry.dayOffset);
if (b == null) return null;
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset);
const [y, m, d] = dayKey.split("-").map(Number);
return {
a: entry.value,
b,
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
date: new Date(Date.UTC(y, m - 1, d)),
dayKey,
};
})
.filter((entry): entry is PairedPoint => entry !== null);
.filter(
(entry): entry is PairedPoint & { dayKey: string } => entry !== null,
);
}
export async function generateWeightStatusForUser(
@@ -169,7 +179,6 @@ export async function generateWeightStatusForUser(
});
const now = new Date();
const nowMs = now.getTime();
const weightSeries = applyPayloadBudget(
measurements
@@ -232,7 +241,7 @@ export async function generateWeightStatusForUser(
diaSeries.daily,
now,
).map((entry) => ({
day: toBerlinDayKey(entry.date),
day: entry.dayKey,
sys: entry.a,
dia: entry.b,
mean: round((entry.a + entry.b) / 2, 2),
@@ -242,9 +251,7 @@ export async function generateWeightStatusForUser(
// pairDailyBuckets — derive dayOffset from the offsets in sysSeries.
const sysOffsetByDay = new Map(
sysSeries.daily.map((bucket) => {
const dayKey = toBerlinDayKey(
new Date(nowMs - bucket.dayOffset * MS_PER_DAY),
);
const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset);
return [dayKey, bucket.dayOffset];
}),
);
@@ -267,7 +274,7 @@ export async function generateWeightStatusForUser(
const latestWeight = weightSeries.daily[0] ?? null;
const previousWeight = weightSeries.daily[1] ?? null;
const latestWeightDay = latestWeight
? toBerlinDayKey(new Date(nowMs - latestWeight.dayOffset * MS_PER_DAY))
? dayOffsetToBerlinDayKey(now, latestWeight.dayOffset)
: null;
const sameDayBp = latestWeightDay
? (pairedSystolicDiastolic.find((entry) => entry.day === latestWeightDay) ??
@@ -338,7 +345,7 @@ export async function generateWeightStatusForUser(
weightVsSystolic: {
correlation: weightVsSystolicCorrelation,
pairs: weightVsSystolicPairs.map((entry) => ({
day: toBerlinDayKey(entry.date),
day: entry.dayKey,
weight: round(entry.a, 2),
systolic: round(entry.b, 2),
})),
@@ -346,7 +353,7 @@ export async function generateWeightStatusForUser(
weightVsMeanBloodPressure: {
correlation: weightVsMeanBpCorrelation,
pairs: weightVsMeanBpPairs.map((entry) => ({
day: toBerlinDayKey(entry.date),
day: entry.dayKey,
weight: round(entry.a, 2),
meanBloodPressure: round(entry.b, 2),
})),