From 790fecddb843a352168219c816a8a2db9fdf3de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:28:04 +0200 Subject: [PATCH] fix(doctor-report): aggregate sleep per night, not per stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor-report PDF computed SLEEP_DURATION min/avg/max over the raw per-stage rows (a 40-minute DEEP block, a 12-minute AWAKE block), so its sleep figure was per-stage-row minutes while every other surface — the dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed — shows the reconstructed per-night asleep total. The sleep-stamping work rewrites exactly those raw rows, so the report could drift independently. Route the report sleep value through the night reconstruction engine, the same way the recovery score routes through the canonical recovery resolver — one number, every surface. The user's source priority feeds the reconstruction so the multi-source de-dup matches the dashboard. --- src/lib/__tests__/doctor-report-data.test.ts | Bin 10248 -> 12832 bytes src/lib/doctor-report-data.ts | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index a1fae17148cb5765f054afeb688a737d98baa530..9c3bfc1f14138b9310fca171e47f9f09f575f1e3 100644 GIT binary patch delta 2556 zcmaJ@O>f&q5Jj%ZrI)rqPXnDuSfXjwZDSb@px8p{Hnt2~X@D9=ye3!TBIGW+Tw1aN zgE#M z3!}@(oTZ%S$6}P2qKQw6GRv42w|wQ?+~geW*_mM@eyYYFHJo-|L(X-pKwuTe8Zeet;{rSe?d`CY2?_iC(;i%h9j)dg?ES@MJ7m~m+Gi@D{ z*bwTI!0JX^MjVD(W%f*^F;tq3=k20U!%{{@C<)Xa(v2qy)xlp{$PqG5r}LjRvmK<8 znDjf#nzK?N`I=J8`UgP98B)vRuIW@D`4|pRDwM`2s_A9?*3?@P7_}2k{E1nXb6`#V9rd^S)6FsyP^rPRyo{qty)eL zO)%^&Cn-#$<-F2vw=U_1v`aI%vJ0(N!OMi!N;`|IQlv3X(PFEubPDY4`i0<;ZV}3D z6Gt$uQa>1q)Nt+9Lhz(r*)njTkLpeM3_>>@zSC(fE4#*jq9LM;E_INx+*=<8ou{?$ z+i9rg{RremCEkqss${Y9clG6V19u2!-7Z}& zKJ|C28J^kH!NafjcBn1ic$?rx6b5ivQewJ?)=j!p2H3Q=>1BnxQ;cwNIgosk^T>Ga z>T{g=RbW)_my)I4IZo_FO>S-JD!2(ERZ8Qd<#XfEHW}Ii0U}ygRv!@rrU+om;GEfeVvOV5~~K5U!RKjeEgf$8J$f?_ON5mb|um zXa~$atLzmkxj=3;am<{E7Tb!jW(u<{xBAjnraG`)S}ynqM-oLi@q!?jIs4PvaUnPf z6U!Ag#F-1Z*1SEnMxIXGzZ+H*T+@x$u|?H?Wu$^s$q`oJ4oEJlSlr%xVl>ZqEm_y& Z-8y>>c43?lFKWs)eSJY6ufM$i>_7XzE7Je~ delta 17 ZcmZ3G(h;y>D&uAzrk|plmnl{W002XU2KE2| diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 62ddc5ee0..f08da5c08 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -27,6 +27,10 @@ import type { MeasurementSource, } from "@/generated/prisma/client"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; import { DEFAULT_DOCTOR_REPORT_PREFS, type DoctorReportPrefs, @@ -685,6 +689,10 @@ export async function collectDoctorReportData( // v1.17 W1a — the user timezone anchors the ledger compliance // band minter (matches the detail page's dose-day attribution). timezone: true, + // v1.17.1 — source-priority feeds the per-night sleep reconstruction + // so the report's SLEEP_DURATION resolves the same canonical night + // (multi-source de-dup) the dashboard + iOS feed show. + sourcePriorityJson: true, // v1.7.0 — patient-identity fields for the export cover + FHIR // Patient. KVNR is encrypted (and not selected here) — the route // decrypts it and hands it to the builders. @@ -708,6 +716,36 @@ export async function collectDoctorReportData( }); } + // v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a + // 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface + // (dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed) + // shows the per-night reconstructed asleep total via `summarizeSleepNights`, + // and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route + // the report's sleep value through the same engine — one number, one surface + // — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`. + const reportTz = userProfile?.timezone ?? "Europe/Berlin"; + const sleepRows = measurements.filter( + (m) => m.type === "SLEEP_DURATION", + ) as unknown as SleepStageRow[]; + if (sleepRows.length > 0) { + const nights = reconstructSleepNights( + sleepRows, + reportTz, + userProfile?.sourcePriorityJson ?? null, + ).filter((n) => n.asleepMinutes > 0); + // Per-night asleep totals, ascending by night, replace the raw per-stage + // rows so the clinical vitals table reads time-asleep hours, not stage-row + // minutes. Empty (no scorable night) drops SLEEP_DURATION entirely. + if (nights.length > 0) { + byType.SLEEP_DURATION = nights.map((n) => ({ + value: n.asleepMinutes, + measuredAt: n.measuredAt.toISOString(), + })); + } else { + delete byType.SLEEP_DURATION; + } + } + // Per-type stats. const stats: Record = {}; for (const [type, entries] of Object.entries(byType)) { @@ -728,7 +766,6 @@ export async function collectDoctorReportData( // detail page (slot dedup, band timing, cadence honoured). As-needed / PRN // medications are excluded — no schedule, no expected dose, no fabricated // 100 % on a clinical report. The medication itself stays on the list. - const reportTz = userProfile?.timezone ?? "Europe/Berlin"; const compliance = buildLedgerCompliance( medications.map((m) => ({ id: m.id,