From cb6a59a431ade129aa597b8a2dae78acf77d6084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 16:02:26 +0200 Subject: [PATCH] fix(insights): correct DST handling in cross-metric daily bucket pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../insights/__tests__/bucket-series.test.ts | 68 ++++++++++++++++++- src/lib/insights/blood-pressure-status.ts | 38 +++++++---- src/lib/insights/bucket-series.ts | 32 +++++++++ src/lib/insights/mood-status.ts | 20 ++++-- src/lib/insights/weight-status.ts | 35 ++++++---- 5 files changed, 162 insertions(+), 31 deletions(-) diff --git a/src/lib/insights/__tests__/bucket-series.test.ts b/src/lib/insights/__tests__/bucket-series.test.ts index 8ffb16c61..cea7be1f9 100644 --- a/src/lib/insights/__tests__/bucket-series.test.ts +++ b/src/lib/insights/__tests__/bucket-series.test.ts @@ -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"); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index a607a5e8e..635dffc84 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -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 { 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), })), diff --git a/src/lib/insights/bucket-series.ts b/src/lib/insights/bucket-series.ts index 07040b70e..ced23921b 100644 --- a/src/lib/insights/bucket-series.ts +++ b/src/lib/insights/bucket-series.ts @@ -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; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 74714f9e2..1d5a37f8c 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -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 { 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( diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index b67d5b65c..66feee3ea 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -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 { 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), })),