From ea30317723962409cbfddeb9cb58dc202303a2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 11:34:03 +0200 Subject: [PATCH] fix(insights): use the reconstructed sleep night in status and null-out unestablished readiness contributors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic metric-status generator read SLEEP_DURATION row-per-stage and fed the snapshot a single sleep STAGE (~16 min) as the current value, so the sleep prose reported a fraction of an hour for a full night. Route the SLEEP_DURATION status/snapshot through reconstructSleepNights — the same per-night, source-deduped, midnight-aware time-asleep total the dashboard, series, and targets surfaces use — so the snapshot's series, summary, and latest value are all the night total in minutes (the registry unit and normal range are already minutes). Gated to SLEEP_DURATION; no other metric's status path changes. The readiness wellness index could surface a contributor value of 0 for an account that recorded resting heart rate / HRV / respiratory rate but had no scorable baseline: a non-finite baseline center or today value fell through clamp100 to a misleading 0 that reads as a real worst-case sub-score. Return null instead so the client drops the contributor, and document the null-vs-0 contract: null means no usable signal (no baseline, no recent reading, non-finite input); a finite value — including a genuine 0 — means the baseline is established and the latest reading really deviates that far. (cherry picked from commit 0422ac012765f30069a41f662c2b025197ed9a88) --- .../insights/__tests__/metric-status.test.ts | 67 +++++++++++++++++ .../derived/__tests__/readiness.test.ts | 46 ++++++++++++ src/lib/insights/derived/readiness.ts | 25 ++++++- src/lib/insights/metric-status.ts | 75 ++++++++++++++++--- 4 files changed, 198 insertions(+), 15 deletions(-) diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 1fc2c4ccd..9f0beb8ec 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -22,7 +22,20 @@ vi.mock("@/lib/insights/metric-correlation-context", () => ({ getRelevantCorrelationsForMetric: vi.fn().mockResolvedValue([]), })); +vi.mock("@/lib/tz/resolver", async () => { + const actual = await vi.importActual( + "@/lib/tz/resolver", + ); + return { ...actual, resolveUserTimezone: vi.fn() }; +}); + +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(), +})); + import { prisma } from "@/lib/db"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context"; import { formatPreviousContextForPrompt } from "@/lib/insights/memory"; @@ -68,6 +81,60 @@ beforeEach(() => { // are no-ops unless a test overrides them. vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValue([]); vi.mocked(formatPreviousContextForPrompt).mockReturnValue(""); + vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin"); + vi.mocked(loadUserSourcePriority).mockResolvedValue(null as never); +}); + +describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)", () => { + it("feeds the snapshot the per-night time-asleep total, not a single stage", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date("2026-06-04T07:00:00.000Z"), + } as never); + + // One overnight session of granular stages, each row ONE stage (minutes). + // Total time asleep = CORE + DEEP + REM = 300 + 90 + 75 = 465 min (7.75 h). + // A single stage (DEEP, 90 min) is the largest, and an old per-row read + // would have surfaced the LATEST stage (~75 min REM) as "current sleep". + const wake = new Date("2026-06-04T06:30:00.000Z"); + const m = 60 * 1000; + const stageRows = [ + { value: 300, measuredAt: new Date(wake.getTime() - 90 * m), sleepStage: "CORE", source: "APPLE_HEALTH" }, + { value: 90, measuredAt: new Date(wake.getTime() - 30 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" }, + { value: 75, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" }, + ]; + vi.mocked(prisma.measurement.count).mockResolvedValue( + stageRows.length as never, + ); + vi.mocked(prisma.measurement.findMany).mockResolvedValue( + stageRows as never, + ); + + const capture = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion("Summary: solide Nacht.", capture); + + const res = await generateMetricStatus({ + metric: "SLEEP_DURATION", + userId: "u1", + locale: "de", + force: true, + }); + + expect(res.text).toBeTruthy(); + expect(capture.userPrompt).toBeTruthy(); + const prompt = capture.userPrompt as string; + // The snapshot must carry the NIGHT total (465 min), never a lone stage. + expect(prompt).toContain("465"); + expect(prompt).not.toContain('"value": 75'); + expect(prompt).not.toContain('"value": 90'); + }); }); describe("metric-status registry", () => { diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts index 55cac9774..ddc506bc5 100644 --- a/src/lib/insights/derived/__tests__/readiness.test.ts +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -168,6 +168,52 @@ describe("computeReadiness gating", () => { } }); + it("emits null (not 0) for a vital with readings below the baseline floor (iOS F2)", async () => { + // The user HAS recorded RHR + HRV + respiratory, but only on 2 distinct + // days each — below the baseline-band history floor. The contributor must + // be NULL (dropped → iOS hides it), never 0 (which would read as a real + // worst-case sub-score). Mood + sleep carry the headline so the blend + // still reaches the minimum-components floor. + const DAY_MS = 24 * 60 * 60 * 1000; + const sparse = (type: string, base: number) => + Array.from({ length: 2 }, (_, i) => ({ + value: base, + measuredAt: new Date(NOW.getTime() - (i + 1) * DAY_MS), + type, + })); + moodFindMany.mockResolvedValue( + Array.from({ length: 8 }, (_, i) => ({ + score: 4, + moodLoggedAt: new Date(`2026-05-${String(20 + i).padStart(2, "0")}T20:00:00Z`), + })), + ); + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return sparse(type, 58); + if (type === "HEART_RATE_VARIABILITY") return sparse(type, 65); + if (type === "RESPIRATORY_RATE") return sparse(type, 14); + if (type === "SLEEP_DURATION") { + return [ + { value: 420, measuredAt: new Date("2026-05-31T06:00:00Z"), sleepStage: "ASLEEP" }, + { value: 430, measuredAt: new Date("2026-06-01T06:10:00Z"), sleepStage: "ASLEEP" }, + { value: 410, measuredAt: new Date("2026-06-02T06:05:00Z"), sleepStage: "ASLEEP" }, + ]; + } + return []; + }, + ); + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + for (const key of ["rhr", "hrv", "respiratory"] as const) { + const c = result.value.components.find((x) => x.key === key)!; + expect(c.value).toBeNull(); + expect(c.value).not.toBe(0); + } + } + }); + it("provenance source is 'live' for a blend backed only by sleep + mood", async () => { // No vital readings (RHR/HRV/resp all gate). Mood entries present + a // scorable sleep night → the two present components are both live reads, diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts index 88ce49b7b..d75c1fa10 100644 --- a/src/lib/insights/derived/readiness.ts +++ b/src/lib/insights/derived/readiness.ts @@ -194,8 +194,19 @@ async function readLatestDayMean( /** * Score one baseline-deviation vital component (RHR / HRV / respiratory). - * Returns null when the baseline is insufficient or there is no recent - * reading — the component then drops from the blend. + * + * null-vs-0 contract (iOS v0.14.3 F2) + * ----------------------------------- + * A contributor is `null` ONLY when there is no usable signal: the baseline + * is not established (insufficient history / band failed), there is no recent + * reading, or the inputs are non-finite. A `null` contributor drops from the + * blend and the client hides it. A FINITE number — including a genuine `0` — + * means the baseline IS established and the latest reading really does deviate + * that far (≥ 2 spreads in the unfavourable direction maps to 0; see + * `scoreDeviation`). We deliberately never emit `0` as a "missing data" + * sentinel: the previous behaviour of letting a non-finite `today`/`center` + * fall through `clamp100` could surface a misleading 0 for an account that had + * the metric but no scorable baseline — that case is now `null`. */ async function scoreVitalDeviation( userId: string, @@ -225,7 +236,15 @@ async function scoreVitalDeviation( }; } const today = await readLatestDayMean(userId, type, windowDays, now); - if (today == null) { + // No recent reading, or a non-finite baseline center / today value: there + // is no scorable deviation. Return null (hidden) rather than letting a NaN + // fall through `scoreDeviation` → `clamp100`, which would surface a + // misleading 0 for an account that actually has the metric (iOS F2). + if ( + today == null || + !Number.isFinite(today) || + !Number.isFinite(baseline.value.center) + ) { return { value: null, source: baseline.provenance.source, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 2fb488d0b..032ee9f7a 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -59,6 +59,12 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { readFreshStatusText, resolveReadOnlyStatusMiss, @@ -206,19 +212,64 @@ export async function generateMetricStatus(args: { // Bounded raw read for the derived recent stats; the monthly/yearly // tail comes from the rollup tier (full-history fallback on a miss), // exactly as the specialised cards do. - const measurements = await prisma.measurement - .findMany({ - where: { userId: args.userId, type: meta.measurementType, deletedAt: null }, - orderBy: { measuredAt: "desc" }, - take: 365, - select: { value: true, measuredAt: true }, - }) - .then((rows) => rows.reverse()); + // + // SLEEP_DURATION is the one type stored ONE ROW PER STAGE per night + // (IN_BED / AWAKE / CORE / DEEP / REM, minutes each), so a raw per-row + // read would feed the status prose a single ~16 min stage as the + // "current sleep" instead of the night total. Route it through + // `reconstructSleepNights` (the same helper the dashboard / series / + // targets use) so every derived value here — `series`, `summary`, + // `latest` — is a per-night TIME-ASLEEP total in minutes (the registry + // unit + normalRange are already minutes). The night reconstruction + // clusters stages into sessions, dedups multi-source nights via the + // user's `sleep` priority ladder, and handles the midnight split. Gated + // to SLEEP_DURATION only so no other metric's status path changes. + const isSleep = meta.measurementType === "SLEEP_DURATION"; - const points = measurements.map((m) => ({ - measuredAt: m.measuredAt, - value: m.value, - })); + const points: Array<{ measuredAt: Date; value: number }> = []; + let measurements: Array<{ measuredAt: Date }> = []; + if (isSleep) { + const [tz, priorityJson, sleepRows] = await Promise.all([ + resolveUserTimezone(args.userId), + loadUserSourcePriority(args.userId), + prisma.measurement.findMany({ + where: { + userId: args.userId, + type: "SLEEP_DURATION", + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year + select: { value: true, measuredAt: true, sleepStage: true, source: true }, + }), + ]); + const nights = reconstructSleepNights( + sleepRows as SleepStageRow[], + tz, + priorityJson, + ).filter((n) => n.asleepMinutes > 0); + for (const n of nights) { + points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes }); + } + measurements = nights.map((n) => ({ measuredAt: n.measuredAt })); + } else { + const rows = await prisma.measurement + .findMany({ + where: { + userId: args.userId, + type: meta.measurementType, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + take: 365, + select: { value: true, measuredAt: true }, + }) + .then((r) => r.reverse()); + for (const m of rows) { + points.push({ measuredAt: m.measuredAt, value: m.value }); + } + measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); + } const series = applyPayloadBudget(points, { now }); const graded = await buildGradedSeriesWithRollups( args.userId,