fix(insights): unify sleep reconstruction and make WHOOP recovery canonical

Route the derived Sleep Score through the canonical night reconstruction
instead of its own UTC-day grouping, so a night logged by more than one
source is deduplicated to a single writer rather than summed. The score and
the readiness that folds it now read true per-night totals; multi-source
nights drop to their real figure. Emitted minutes and the night stage map
round to integers.

Resolve recovery to one canonical series — WHOOP-native when present, the
computed proxy as fallback — in the wellness reader, the doctor report, and
the coach snapshot, and stop presenting the proxy and the native score as two
numbers. A WHOOP-connected account's recovery now reads the native value on
the tile, the chart, and the printed report alike.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:24:59 +02:00
commit e27bd8b9bf
16 files changed
+529 -106

No files matched your search

+8 -1
View File
@@ -64,7 +64,14 @@ function serializeSession(s: SleepSession) {
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
awakenings: s.awakenings,
stages: s.stages,
// iOS #18 — round the per-stage map too; the underlying totals sum
// second-precision segments and otherwise serialise as e.g. 88.4999.
stages: Object.fromEntries(
Object.entries(s.stages).map(([stage, minutes]) => [
stage,
Math.round(minutes),
]),
),
segments: s.segments.map((seg) => ({
stage: seg.stage,
start: seg.start.toISOString(),
+9
View File
@@ -319,6 +319,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
updatedAt: row.updatedAt.toISOString(),
});
} else {
// Every row carries its `source`. RECOVERY_SCORE is written by BOTH the
// WHOOP-native sync (`source = WHOOP`) and the COMPUTED proxy job
// (`source = COMPUTED`) for the same day; the client must resolve to ONE
// canonical recovery series — WHOOP wins when present, COMPUTED is the
// fallback — and never chart the proxy and the native value as two
// competing series. The feed faithfully mirrors both rows (a delta-sync
// mirror cannot drop a row mid-keyset without a tombstone); the
// canonical pick is the same WHOOP-over-COMPUTED rule the server's
// wellness tile + doctor PDF apply (`resolveCanonicalRecovery`).
measurementUpserts.push({
id: row.id,
externalId: row.externalId,