From b9a3f577ca355f57fb852633b5711a6c8e8155e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 02:55:46 +0200 Subject: [PATCH] fix(doctor-report): collapse to the canonical source before per-type stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-type avg/min/max blended every ingest source, so a metric carried by two overlapping sources on the same day (WHOOP + Apple Watch resting heart rate) averaged both readings while the dashboard and rollups already collapse to the ladder-canonical source per day. Route the report's raw measurements through the same picker the analytics aggregator uses (pickCanonicalSourceRows, keyed by metricKeyForType + the user day key + source-priority ladder) before grouping, so the PDF and FHIR numbers match the dashboard. SLEEP_DURATION stays untouched — its per-stage rows are resolved by the per-night reconstruction downstream. Adds a parity test. --- src/lib/__tests__/doctor-report-data.test.ts | Bin 12832 -> 15931 bytes src/lib/doctor-report-data.ts | 90 ++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 9c3bfc1f14138b9310fca171e47f9f09f575f1e3..85b19408593ad3b8becd82a3ca63a7b79642bb7f 100644 GIT binary patch delta 2935 zcmb_eO>Y}T7*-_^qMQm9&>ZNCx$FvO?WAcHISR#T45>(J%XWp@C`!k>V|$g|on>a$ zi3!niLgGf{{s8^}CyxCc+>nrZXTQV)1BNge0Jh0QPE#M6(u8#A(WGbfW#quA_m8e~3o z98!gW-b&ze7*wI|Le1`pXfl#xTYVfR4cH@c?BPGh^Gs;rmh0riu9K5X*8LAymdLUI z6MXjP(kD5AIE!SsT)-BPDM$u#Ou@GlwvY;Qzq9=nPMb)GiS)-*=*=ZtDphcFPiAbj z8nhD%Ta-s(f}EOGq1)>9A3pl}aHrMm_VdGTv)?iVmd6Yjjtu3LBA1G^!h`;pY7Stq zG7nfvV}4UtM1p|wA9eDNgOK5gFyDgP?x4&Z|<~0~H4$n|B5_9fQ!ey68 z`pn%heQ8}lp^G@fi3cW~ATg~%!Xyk3&cM_03?~#5kP-X(7NAZl3q9+W5vR5FD&&>b z`s%&qI(}CA^?Qwa9Y0TTv7)Q4loP%x$h|u5eJ#da8)Lp-8|q*~(H#&!Mc>@Q7+}Hn z{}ooIB~gX`Q*0X*}@M+B`)hed**FN!K0hjOf+QBwlL4Ahun z_Ys}W%_^T`)aF%YYYM^fF|HOCSafck2hQAbdX?AtRVkF4YbdRZoj^l) z?(I&X@Gio*Udin)R$?Kno!h2Wu^l8fOH%DVZNaWy+$<7%!aS?jxuM9%)&`T@>UaSHM8=)6fE9Z#x!^oK-!|u8!9*7r)mmn_ZfcUPbi&A zN(8bPQ*xG##y~W`45t%j-Pn+NcJ$G$S$^Z4{DS3d?Y7(7haGgD-s5iTu=k{guClWF zx?QZ#SH41H9mq)p?5M{MrU(c@$gMM>vHzfl9-E9^SE#p{f=IX?aixbxq z_>Sbvv1VWQy>h2+h&l&#M9Bo9?(Mc(ox| z+B$h=-hu*lj7ItviYlI^H$J>Lh{f2-_ZQGZ+h3Uj@1pHrczzLme13KA`TPpc{~u?7 zD&J!0uPL6kNbM0dc;VacB%C~>@ao4GAR=@g2t|3O;A( + measurements: readonly T[], + timezone: string, + sourcePriorityJson: unknown, +): T[] { + const dayKey = (d: Date) => userDayKey(d, timezone); + const byType = new Map(); + for (const m of measurements) { + const slot = byType.get(m.type); + if (slot) slot.push(m); + else byType.set(m.type, [m]); + } + const out: T[] = []; + for (const [type, rows] of byType) { + const metricKey = + type === "SLEEP_DURATION" + ? null + : metricKeyForType(type as MeasurementType); + if (!metricKey) { + out.push(...rows); + continue; + } + const { canonicalRows } = pickCanonicalSourceRows( + rows.map((m) => ({ + ...m, + type: m.type as MeasurementType, + })), + metricKey, + sourcePriorityJson, + dayKey, + ); + // `pickCanonicalSourceRows` returns the spread copies; re-key back to the + // original rows by reference identity is unnecessary because the spread + // preserves every field the caller reads (value / measuredAt / type). + out.push(...(canonicalRows as unknown as T[])); + } + return out; +} + /** * Summarise RECOVERY_SCORE over the window from the CANONICAL row per day: * a WHOOP-native row wins over the COMPUTED proxy for the same day, so the @@ -739,18 +807,35 @@ export async function collectDoctorReportData( }), ]); - // Group measurements by type. + const reportTz = userProfile?.timezone ?? "Europe/Berlin"; + + // v1.18.0 — collapse each multi-source metric to its CANONICAL source before + // grouping, so the report's per-type avg/min/max match the dashboard rather + // than blending overlapping sources (e.g. WHOOP + Apple Watch resting-heart- + // rate summed into one inflated mean). See `collapseMeasurementsToCanonical`. + const canonicalMeasurements = collapseMeasurementsToCanonical( + measurements, + reportTz, + userProfile?.sourcePriorityJson ?? null, + ); + + // Group measurements by type (canonical-source-collapsed). const byType: Record< string, Array<{ value: number; measuredAt: string }> > = {}; - for (const m of measurements) { + for (const m of canonicalMeasurements) { if (!byType[m.type]) byType[m.type] = []; byType[m.type].push({ value: m.value, measuredAt: m.measuredAt.toISOString(), }); } + // measuredAt-ascending within each type so `latest` (last element) is the + // most recent reading after the canonical collapse reorders by bucket. + for (const entries of Object.values(byType)) { + entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt)); + } // 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 @@ -759,7 +844,6 @@ export async function collectDoctorReportData( // 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[];