diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index f4c91885b..086112526 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -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); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index c4acc89c7..0c7ecab10 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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, diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index 8aa2fcb83..8389b3bc9 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -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 = [ diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 1e491bafe..c80071d82 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -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