From 2da7ea088161e844e36d27bcce66684c0a567fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 21:23:55 +0200 Subject: [PATCH] Derive recovery rank from the source-priority ladder --- .../__tests__/recovery-resolve.test.ts | 23 ++++++++++++++++++ src/lib/insights/derived/recovery-resolve.ts | 24 +++++++++---------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/lib/insights/derived/__tests__/recovery-resolve.test.ts b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts index b393ee458..df9487738 100644 --- a/src/lib/insights/derived/__tests__/recovery-resolve.test.ts +++ b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts @@ -3,6 +3,7 @@ import { resolveCanonicalRecovery, type RecoveryRow, } from "../recovery-resolve"; +import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority"; function row(iso: string, value: number, source: string): RecoveryRow { return { value, measuredAt: new Date(iso), source: source as never }; @@ -116,4 +117,26 @@ describe("resolveCanonicalRecovery", () => { it("returns an empty list for no rows", () => { expect(resolveCanonicalRecovery([])).toEqual([]); }); + + // The per-source authority is derived from `DEFAULT_SOURCE_PRIORITY.recovery` + // (one ordered ladder), not a second hardcoded rank map. For every adjacent + // pair on that ladder the earlier source must win the same night, proving the + // resolution order tracks the ladder rather than a copy of it. + it("resolves in the order of DEFAULT_SOURCE_PRIORITY.recovery", () => { + const ladder = DEFAULT_SOURCE_PRIORITY.recovery; + for (let i = 0; i < ladder.length - 1; i++) { + const higher = ladder[i]; + const lower = ladder[i + 1]; + // COMPUTED carries the day-that-ended stamp; everything else stamps the + // wake morning. Stamp both for the SAME wake day so they collide. + const stampFor = (src: string) => + src === "COMPUTED" ? "2026-06-01T12:00:00Z" : "2026-06-02T06:00:00Z"; + const resolved = resolveCanonicalRecovery([ + row(stampFor(lower), 10, lower), + row(stampFor(higher), 90, higher), + ]); + expect(resolved).toHaveLength(1); + expect(resolved[0].source).toBe(higher); + } + }); }); diff --git a/src/lib/insights/derived/recovery-resolve.ts b/src/lib/insights/derived/recovery-resolve.ts index 4f8c8cfed..b87298301 100644 --- a/src/lib/insights/derived/recovery-resolve.ts +++ b/src/lib/insights/derived/recovery-resolve.ts @@ -41,6 +41,7 @@ import type { MeasurementSource } from "@/generated/prisma/client"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; +import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -53,22 +54,19 @@ export interface RecoveryRow { /** * The per-source preference for `RECOVERY_SCORE`: a native device row outranks - * the COMPUTED proxy for the same day. Lower number = higher authority. The - * ladder mirrors `DEFAULT_SOURCE_PRIORITY.recovery` - * (`WHOOP > OURA > POLAR > COMPUTED`): WHOOP's all-night strap leads, then the - * Oura ring readiness and the Polar Nightly Recharge band, then the computed - * proxy as the fallback for users without a wearable. A source not listed falls - * below all of them. + * the COMPUTED proxy for the same day. Authority is read straight off + * `DEFAULT_SOURCE_PRIORITY.recovery` (`WHOOP > OURA > POLAR > COMPUTED`) — that + * ordered ladder is the single source of truth, so adding a recovery source + * updates only the priority list and this resolver inherits the new rank for + * free. WHOOP's all-night strap leads, then the Oura ring readiness and the + * Polar Nightly Recharge band, then the computed proxy as the fallback for + * users without a wearable. A source not on the ladder falls below all of them. */ -const RECOVERY_SOURCE_RANK: Partial> = { - WHOOP: 0, - OURA: 1, - POLAR: 2, - COMPUTED: 3, -}; +const RECOVERY_LADDER = DEFAULT_SOURCE_PRIORITY.recovery; function rankOf(source: MeasurementSource): number { - return RECOVERY_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER; + const idx = RECOVERY_LADDER.indexOf(source); + return idx === -1 ? Number.MAX_SAFE_INTEGER : idx; } /**