diff --git a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts new file mode 100644 index 000000000..f2d97fc0e --- /dev/null +++ b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts @@ -0,0 +1,157 @@ +/** + * Unit coverage for `loadIntradayPulse` — the two DATAINT-audit fixes: + * + * - M2: the PULSE/step reads are bounded to the exact local-day window + * (`localDayWindow`), not the ±15h/39h padded superset a dense account's + * read cap could be entirely consumed by (the previous evening's + * samples starving the viewed day's afternoon). + * - M5: the resting-baseline PULSE-proxy fallback buckets by the CALLER's + * timezone, not a hardcoded Berlin default — `resolveBaseline` now + * threads `timezone` into `resolveRestingPulseSeries`'s `dayKeyOf`. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const findManyMock = vi.fn(); +const workoutFindManyMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: (...args: unknown[]) => findManyMock(...args) }, + workout: { findMany: (...args: unknown[]) => workoutFindManyMock(...args) }, + }, +})); + +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { + resolveRestingPulseSeries, + type PulseSample, +} from "@/lib/analytics/resting-pulse"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; +import { percentile } from "@/lib/insights/strain-score"; +import { localDayWindow } from "@/lib/measurements/consolidation-tz"; + +/** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */ +function median(values: number[]): number | null { + if (values.length === 0) return null; + return Math.round(percentile(values, 50) * 10) / 10; +} + +describe("loadIntradayPulse", () => { + beforeEach(() => { + findManyMock.mockReset(); + workoutFindManyMock.mockReset(); + workoutFindManyMock.mockResolvedValue([]); + }); + + it("M2 — queries PULSE/step rows against the exact local-day window, not the padded lead/trail superset", async () => { + const tz = "America/New_York"; + const dateKey = "2026-06-15"; + const { dayStart, dayEnd } = localDayWindow(dateKey, tz); + + findManyMock.mockResolvedValue([]); + + await loadIntradayPulse("user-1", tz, dateKey); + + const pulseCall = findManyMock.mock.calls.find( + (call) => call[0]?.where?.type === "PULSE", + ); + const stepCall = findManyMock.mock.calls.find( + (call) => call[0]?.where?.type === "ACTIVITY_STEPS", + ); + expect(pulseCall).toBeDefined(); + expect(stepCall).toBeDefined(); + expect(pulseCall![0].where.measuredAt).toEqual({ + gte: dayStart, + lt: dayEnd, + }); + expect(stepCall![0].where.measuredAt).toEqual({ + gte: dayStart, + lt: dayEnd, + }); + }); + + it("M5 — the PULSE-proxy baseline buckets by the caller's timezone, not Berlin", async () => { + const tz = "Pacific/Kiritimati"; // UTC+14, no DST — deterministic offset + const dateKey = "2026-06-15"; + + // Two UTC instants that land on DIFFERENT Kiritimati calendar days but + // the SAME Berlin calendar day (Kiritimati is ~13h ahead of Berlin in + // June) — a clean tz-bucketing fork. + const dayA = [ + new Date("2026-06-10T09:00:00.000Z"), // Kiritimati: 2026-06-10T23:00 (day A) + new Date("2026-06-10T10:00:00.000Z"), + new Date("2026-06-10T11:00:00.000Z"), + ]; + const dayB = [ + new Date("2026-06-10T12:00:00.000Z"), // Kiritimati: 2026-06-11T02:00 (day B) + new Date("2026-06-10T13:00:00.000Z"), + new Date("2026-06-10T14:00:00.000Z"), + ]; + expect(userDayKey(dayA[0], tz)).not.toBe(userDayKey(dayB[0], tz)); + // Sanity: under the OLD hardcoded-Berlin bug all six samples fall on the + // SAME Berlin day — proving the two tz groupings genuinely diverge. + const berlinDays = new Set( + [...dayA, ...dayB].map((d) => toBerlinDayKey(d)), + ); + expect(berlinDays.size).toBe(1); + + const pulseHistory: Array<{ value: number; measuredAt: Date }> = [ + ...dayA.map((measuredAt, i) => ({ value: 50 + i, measuredAt })), + ...dayB.map((measuredAt, i) => ({ value: 80 + i, measuredAt })), + ]; + + findManyMock.mockImplementation( + async (args: { where: { type: string } }) => { + if (args.where.type === "PULSE") return []; + if (args.where.type === "RESTING_HEART_RATE") return []; + return []; + }, + ); + // `resolveBaseline` fires a SECOND PULSE read (take: 365, no window) when + // resting rows are thin — swap the mock to serve it on the second PULSE + // call specifically by checking call order isn't reliable, so key off + // `select`/`take` shape instead. + findManyMock.mockImplementation( + async (args: { + where: { type: string; measuredAt?: unknown }; + take?: number; + }) => { + if (args.where.type === "RESTING_HEART_RATE") return []; + if (args.where.type === "PULSE" && args.take === 365) { + return pulseHistory; + } + // The view-day-windowed PULSE / ACTIVITY_STEPS reads — irrelevant here. + return []; + }, + ); + + const result = await loadIntradayPulse("user-1", tz, dateKey); + + expect(result.baselineSource).toBe("proxy"); + + const expected = resolveRestingPulseSeries({ + restingSamples: [], + pulseSamples: pulseHistory as PulseSample[], + dayKeyOf: (d) => userDayKey(d, tz), + }); + const expectedBaseline = median( + expected.series.slice(-30).map((p) => p.value), + ); + expect(result.baseline).toBe(expectedBaseline); + + // Regression guard: the OLD hardcoded-Berlin bucketing would have folded + // every sample into ONE day (see the `berlinDays.size === 1` sanity + // check above) and produced a materially different proxy value — assert + // the fixed result does NOT match that wrong grouping's outcome. + const buggyBerlin = resolveRestingPulseSeries({ + restingSamples: [], + pulseSamples: pulseHistory as PulseSample[], + dayKeyOf: (d) => toBerlinDayKey(d), + }); + const buggyBaseline = median( + buggyBerlin.series.slice(-30).map((p) => p.value), + ); + expect(result.baseline).not.toBe(buggyBaseline); + }); +}); diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index df5089d6b..0ec6ce966 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -18,6 +18,8 @@ import { prisma } from "@/lib/db"; import { percentile } from "@/lib/insights/strain-score"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { localDayWindow } from "@/lib/measurements/consolidation-tz"; +import { userDayKey } from "@/lib/tz/format"; import { BUCKET_MINUTES, HOURLY_BUCKET_MINUTES, @@ -73,8 +75,18 @@ function median(values: number[]): number | null { * baseline is the median of the resolved daily series — robust to the odd * outlier — and is "mature" only once at least `MIN_BASELINE_DAYS` distinct * daily points exist. + * + * `timezone` buckets the PULSE-proxy fallback in the USER's local day + * (DATAINT M5) — the caller previously omitted it, so + * `deriveRestingProxyFromPulse` defaulted to Berlin-day buckets for every + * user. A non-Berlin user's late-evening (often workout-heavy) samples then + * folded into the wrong day's bucket, shifting the daily floor the tension + * detector's `baseline + 12 bpm` threshold judges the day against. */ -async function resolveBaseline(userId: string): Promise<{ +async function resolveBaseline( + userId: string, + timezone: string, +): Promise<{ baseline: number | null; mature: boolean; source: "resting" | "proxy" | "none"; @@ -106,6 +118,7 @@ async function resolveBaseline(userId: string): Promise<{ measuredAt: r.measuredAt, value: r.value, })), + dayKeyOf: (d) => userDayKey(d, timezone), }); const recent = resolved.series.slice(-30).map((p) => p.value); @@ -148,6 +161,18 @@ export async function loadIntradayPulse( const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime(); const start = new Date(anchorMs - WINDOW_LEAD_MS); const end = new Date(anchorMs + WINDOW_TRAIL_MS); + // DATAINT M2 — PULSE/step reads are bounded to the EXACT local-day window + // (DST-aware; 23/24/25h), not the ±15h/39h padded superset. The prior + // padded read meant `take: MAX_DAY_*_ROWS` (an ascending cap) could be + // entirely consumed by the PREVIOUS local day's samples (e.g. a + // workout-heavy evening) before reaching the viewed day at all on a dense + // account — the cap now only ever spends against rows that are actually + // inside `dateKey`. `computeTenMinuteMeanSeries` / the step-bucket loop + // already re-filter by `dayKey` defensively, so this is a pure narrowing. + // The workout-overlap read keeps the wider padded window: it has no `take` + // cap, so there is no truncation risk, and a workout can legitimately + // start the local-day before or end the local-day after. + const { dayStart, dayEnd } = localDayWindow(dateKey, timezone); const [pulseRows, stepRows, workoutRows, baseline] = await Promise.all([ prisma.measurement.findMany({ @@ -155,7 +180,7 @@ export async function loadIntradayPulse( userId, type: "PULSE", deletedAt: null, - measuredAt: { gte: start, lte: end }, + measuredAt: { gte: dayStart, lt: dayEnd }, }, orderBy: { measuredAt: "asc" }, take: MAX_DAY_PULSE_ROWS, @@ -166,7 +191,7 @@ export async function loadIntradayPulse( userId, type: "ACTIVITY_STEPS", deletedAt: null, - measuredAt: { gte: start, lte: end }, + measuredAt: { gte: dayStart, lt: dayEnd }, }, orderBy: { measuredAt: "asc" }, take: MAX_DAY_STEP_ROWS, @@ -180,7 +205,7 @@ export async function loadIntradayPulse( }, select: { startedAt: true, endedAt: true }, }), - resolveBaseline(userId), + resolveBaseline(userId, timezone), ]); // Split live PULSE rows into raw per-sample rows and already-folded