mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
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");
|
||||
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
Reference in new issue
Block a user