mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(measurements): drill-down window honours DST transitions in target tz
The W7c drill-down resolved the day window via canonicalDailyTimestamp(dayKey, tz) ± 12 h. The shape worked for any non-DST day because the noon instant carries the zone's offset, but on the two transition days per year the true local day is 23 or 25 hours long while the window always spanned exactly 24 hours. On Berlin spring-forward (Mar 30, 2025) the window leaked the previous day's last hour into the drill-down; on fall-back (Oct 26, 2025) it hid the first hour of `today`. Same shape applies to every IANA zone that observes DST. Add a localDayWindow(dayKey, tz) helper that anchors dayStart and dayEnd at the user's local 00:00 of the requested day and the next day. The next-day anchor pattern picks up the DST shift automatically because each day's offset is read at its own UTC midnight (EU/US DST transitions happen at local 02:00 / 03:00, so the offset at 00:00 UTC is unambiguous). Half-hour zones (Asia/Kolkata UTC+5:30, Pacific/Chatham UTC+12:45) keep their minute component.
This commit is contained in:
1 parent
c2f436720f
commit
c0c1b6b86a
4 files changed
+217
-30
No files matched your search
@@ -233,12 +233,52 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
|
||||
.measuredAt as { gte?: Date; lt?: Date };
|
||||
expect(measuredAt.gte).toBeInstanceOf(Date);
|
||||
expect(measuredAt.lt).toBeInstanceOf(Date);
|
||||
// Window is exactly 24 hours wide for any whole-hour-offset zone.
|
||||
// Window is exactly 24 hours wide on a non-DST-transition day for
|
||||
// a whole-hour-offset zone. The DST-aware drill-down test below
|
||||
// pins the 23-/25-hour shape on the two transition days per year.
|
||||
expect(measuredAt.lt!.getTime() - measuredAt.gte!.getTime()).toBe(
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
// v1.4.37 W10 H-1 — DST awareness. The drill-down window must honour
|
||||
// the day's true local span (23 h on spring-forward, 25 h on
|
||||
// fall-back) so the row count matches what the collapsed daily row
|
||||
// displays. Previous shape (`canonicalDailyTimestamp ± 12h`) leaked
|
||||
// an hour from the previous day on spring-forward and hid the first
|
||||
// hour of `today` on fall-back.
|
||||
it("returns a 23-hour window on the Berlin spring-forward day (2025-03-30)", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2025-03-30"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
|
||||
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
|
||||
.measuredAt as { gte?: Date; lt?: Date };
|
||||
expect(measuredAt.gte!.toISOString()).toBe("2025-03-29T23:00:00.000Z");
|
||||
expect(measuredAt.lt!.toISOString()).toBe("2025-03-30T22:00:00.000Z");
|
||||
expect(measuredAt.lt!.getTime() - measuredAt.gte!.getTime()).toBe(
|
||||
23 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a 25-hour window on the Berlin fall-back day (2025-10-26)", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2025-10-26"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
|
||||
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
|
||||
.measuredAt as { gte?: Date; lt?: Date };
|
||||
expect(measuredAt.gte!.toISOString()).toBe("2025-10-25T22:00:00.000Z");
|
||||
expect(measuredAt.lt!.toISOString()).toBe("2025-10-26T23:00:00.000Z");
|
||||
expect(measuredAt.lt!.getTime() - measuredAt.gte!.getTime()).toBe(
|
||||
25 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed dayKey at the validation gate (no DB call)", async () => {
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=15.05.2026"));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
localDayWindow,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
@@ -85,16 +86,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? user.timezone
|
||||
: "Europe/Berlin";
|
||||
// Resolve `[dayStart, dayEnd)` for the requested calendar day in
|
||||
// the user's local zone. We piggy-back on `canonicalDailyTimestamp`
|
||||
// (which returns the local-noon instant) and shift ±12 h to land
|
||||
// on the day boundary. Works for every IANA offset — including
|
||||
// sub-half-hour zones like Asia/Kathmandu (UTC+5:45) and
|
||||
// Pacific/Chatham (UTC+12:45) — because the noon instant already
|
||||
// carries the zone's offset and the ±12 h shift is independent
|
||||
// of it.
|
||||
const noonLocal = canonicalDailyTimestamp(dayKey, tz);
|
||||
const dayStart = new Date(noonLocal.getTime() - 12 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(noonLocal.getTime() + 12 * 60 * 60 * 1000);
|
||||
// the user's local zone. v1.4.37 W10 — `localDayWindow` honours
|
||||
// DST: the previous shape (`canonicalDailyTimestamp ± 12 h`)
|
||||
// silently leaked or hid one hour of samples on spring-forward
|
||||
// (23-h day) and fall-back (25-h day) by always spanning exactly
|
||||
// 24 h. Works for every IANA offset including sub-half-hour
|
||||
// zones (Asia/Kathmandu UTC+5:45, Pacific/Chatham UTC+12:45).
|
||||
const { dayStart, dayEnd } = localDayWindow(dayKey, tz);
|
||||
const samples = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
drainPerSampleCumulative,
|
||||
localDayWindow,
|
||||
localStartOfDay,
|
||||
sumBucketValues,
|
||||
} from "../drain-per-sample-cumulative";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
@@ -57,6 +59,94 @@ describe("canonicalDailyTimestamp", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("localStartOfDay", () => {
|
||||
// v1.4.37 W10 — local 00:00 resolution for any IANA zone. Used by
|
||||
// the W7c drill-down to anchor the per-day UTC window.
|
||||
|
||||
it("returns Berlin 00:00 CET in winter (UTC+1)", () => {
|
||||
// 2026-01-15 00:00 CET = 2026-01-14 23:00 UTC.
|
||||
const ts = localStartOfDay("2026-01-15", "Europe/Berlin");
|
||||
expect(ts.toISOString()).toBe("2026-01-14T23:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns Berlin 00:00 CEST in summer (UTC+2)", () => {
|
||||
// 2026-05-16 00:00 CEST = 2026-05-15 22:00 UTC.
|
||||
const ts = localStartOfDay("2026-05-16", "Europe/Berlin");
|
||||
expect(ts.toISOString()).toBe("2026-05-15T22:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns plain UTC midnight for tz=UTC", () => {
|
||||
const ts = localStartOfDay("2026-05-16", "UTC");
|
||||
expect(ts.toISOString()).toBe("2026-05-16T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("handles half-hour-offset zones (Asia/Kolkata UTC+5:30)", () => {
|
||||
// 2026-05-16 00:00 IST = 2026-05-15 18:30 UTC.
|
||||
const ts = localStartOfDay("2026-05-16", "Asia/Kolkata");
|
||||
expect(ts.toISOString()).toBe("2026-05-15T18:30:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localDayWindow — DST-aware [dayStart, dayEnd) drill-down bounds", () => {
|
||||
// v1.4.37 W10 H-1 — the previous `canonicalDailyTimestamp ± 12h`
|
||||
// shape silently leaked or hid an hour of samples on the two days
|
||||
// per year when Europe/Berlin (and every other DST-observing IANA
|
||||
// zone) transitions. `localDayWindow` walks the day's true local
|
||||
// 00:00 → next-day 00:00 boundary so the window covers the right
|
||||
// 23 / 24 / 25-hour span.
|
||||
|
||||
it("spring-forward day (Berlin 2025-03-30) is exactly 23 hours wide", () => {
|
||||
const { dayStart, dayEnd } = localDayWindow(
|
||||
"2025-03-30",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
// 2025-03-30 00:00 CET = 2025-03-29 23:00 UTC.
|
||||
// 2025-03-31 00:00 CEST = 2025-03-30 22:00 UTC.
|
||||
expect(dayStart.toISOString()).toBe("2025-03-29T23:00:00.000Z");
|
||||
expect(dayEnd.toISOString()).toBe("2025-03-30T22:00:00.000Z");
|
||||
expect(dayEnd.getTime() - dayStart.getTime()).toBe(23 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("fall-back day (Berlin 2025-10-26) is exactly 25 hours wide", () => {
|
||||
const { dayStart, dayEnd } = localDayWindow(
|
||||
"2025-10-26",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
// 2025-10-26 00:00 CEST = 2025-10-25 22:00 UTC.
|
||||
// 2025-10-27 00:00 CET = 2025-10-26 23:00 UTC.
|
||||
expect(dayStart.toISOString()).toBe("2025-10-25T22:00:00.000Z");
|
||||
expect(dayEnd.toISOString()).toBe("2025-10-26T23:00:00.000Z");
|
||||
expect(dayEnd.getTime() - dayStart.getTime()).toBe(25 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("regular day is exactly 24 hours wide", () => {
|
||||
const { dayStart, dayEnd } = localDayWindow(
|
||||
"2026-05-16",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(dayEnd.getTime() - dayStart.getTime()).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("crosses a month boundary cleanly (2026-05-31 → 2026-06-01)", () => {
|
||||
const { dayStart, dayEnd } = localDayWindow(
|
||||
"2026-05-31",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(dayStart.toISOString()).toBe("2026-05-30T22:00:00.000Z");
|
||||
expect(dayEnd.toISOString()).toBe("2026-05-31T22:00:00.000Z");
|
||||
});
|
||||
|
||||
it("crosses a year boundary cleanly (2025-12-31 → 2026-01-01)", () => {
|
||||
const { dayStart, dayEnd } = localDayWindow(
|
||||
"2025-12-31",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
// Berlin in December is CET (UTC+1).
|
||||
expect(dayStart.toISOString()).toBe("2025-12-30T23:00:00.000Z");
|
||||
expect(dayEnd.toISOString()).toBe("2025-12-31T23:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketRowsByUserDay", () => {
|
||||
it("groups per-sample rows by the user's calendar day", () => {
|
||||
const rows = [
|
||||
|
||||
@@ -103,6 +103,30 @@ export function dayKeyForUserTz(date: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the IANA-zone offset (in minutes east of UTC) at a given
|
||||
* instant. Returns 0 for UTC and any zone the shortOffset formatter
|
||||
* can't resolve (defensive — Node 22's full-icu build covers every
|
||||
* zone we care about).
|
||||
*/
|
||||
function tzOffsetMinutesAt(instant: Date, tz: string): number {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "shortOffset",
|
||||
});
|
||||
const parts = fmt.formatToParts(instant);
|
||||
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
|
||||
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||
if (!match) return 0;
|
||||
const sign = match[1] === "+" ? 1 : -1;
|
||||
const hours = Number.parseInt(match[2], 10);
|
||||
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
|
||||
return sign * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical timestamp for a calendar-day key in the user's
|
||||
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
|
||||
@@ -119,31 +143,66 @@ export function canonicalDailyTimestamp(
|
||||
// build "12:00 UTC of the day", read what wall-clock that shows in
|
||||
// the target zone, then shift by the resulting offset.
|
||||
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "shortOffset",
|
||||
});
|
||||
// The shortOffset entry looks like "GMT+2", "GMT-5:30", or "GMT".
|
||||
const parts = fmt.formatToParts(utcNoon);
|
||||
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
|
||||
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||
if (!match) {
|
||||
// UTC — noon UTC is noon local.
|
||||
return utcNoon;
|
||||
}
|
||||
const sign = match[1] === "+" ? 1 : -1;
|
||||
const hours = Number.parseInt(match[2], 10);
|
||||
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
|
||||
const offsetMinutes = sign * (hours * 60 + minutes);
|
||||
const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz);
|
||||
// utcNoon represents 12:00 UTC. The user's local clock reads
|
||||
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
|
||||
// subtract the offset.
|
||||
return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00
|
||||
* for a calendar-day key. Robust on DST transitions because the offset
|
||||
* is read at the UTC-midnight instant of the day, and EU/US DST
|
||||
* transitions happen at local 02:00 / 03:00 — so the offset at UTC
|
||||
* midnight is unambiguous on every day of the year.
|
||||
*
|
||||
* Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on
|
||||
* a 23-h spring-forward or 25-h fall-back day; the previous shape
|
||||
* (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour
|
||||
* of samples on two days per year.
|
||||
*
|
||||
* Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so
|
||||
* the returned window covers the true local-day span — 23 h on
|
||||
* spring-forward, 24 h on a regular day, 25 h on fall-back.
|
||||
*/
|
||||
export function localStartOfDay(dateKey: string, tz: string): Date {
|
||||
// Anchor at 00:00 UTC of the day; the offset read at that instant
|
||||
// is the same offset the local clock uses at midnight (DST
|
||||
// transitions in EU/US happen at 02:00 / 03:00 local, not at the
|
||||
// 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45,
|
||||
// Pacific/Chatham UTC+12:45) the minute component is preserved.
|
||||
const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`);
|
||||
const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz);
|
||||
// Local 00:00 at this date = UTC midnight - offset.
|
||||
return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a
|
||||
* calendar-day key in the user's IANA timezone. The window honours
|
||||
* DST so the drill-down branch returns the correct 23 / 24 / 25-hour
|
||||
* span for transition days.
|
||||
*
|
||||
* Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING
|
||||
* calendar day — `< dayEnd` is the canonical half-open bound used by
|
||||
* the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate.
|
||||
*/
|
||||
export function localDayWindow(
|
||||
dateKey: string,
|
||||
tz: string,
|
||||
): { dayStart: Date; dayEnd: Date } {
|
||||
const dayStart = localStartOfDay(dateKey, tz);
|
||||
// Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon
|
||||
// sidesteps every DST edge case for the calendar increment). Then
|
||||
// re-extract the ISO date slice — guaranteed to be the next-day key.
|
||||
const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`);
|
||||
nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1);
|
||||
const nextDateKey = nextUtcNoon.toISOString().slice(0, 10);
|
||||
const dayEnd = localStartOfDay(nextDateKey, tz);
|
||||
return { dayStart, dayEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an array of per-sample rows into per-day buckets keyed by the
|
||||
* user's timezone. Exposed for unit testing the bucketing semantics
|
||||
|
||||
Reference in new issue
Block a user