From d570d558fa00a3f2503429aec3b40288a82d22ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:04:04 +0200 Subject: [PATCH] feat(insights): make sleep debt a configurable source ladder with a computed explainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalise sleep debt onto the existing per-metric source-priority picker (a new sleepDebt ladder, native-above-computed, default WHOOP > Oura > Polar > computed) instead of assuming one provider. The rhythm read resolves the active source off the ladder and carries it on the DTO; when the active source is our own COMPUTED engine — every user today, since no provider ships a native sleep-debt balance — the Sleep page explains what the figure means and that it differs from a wearable's native number. The computed model itself is unchanged and correct (a short rolling balance of need minus sleep, surplus-credited); only stale comments that still referenced the old 14-night window are corrected. --- docs/api/openapi.yaml | 129 ++++++++++++++++++ messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- .../__tests__/chronotype-section.test.tsx | 1 + .../__tests__/sleep-rhythm-cards.test.tsx | 2 + .../__tests__/sleep-rhythm-section.test.tsx | 1 + .../insights/sleep/sleep-debt-card.tsx | 8 ++ src/components/settings/sources-section.tsx | 2 + src/lib/insights/derived/sleep-rhythm.ts | 49 +++++-- src/lib/openapi/routes/measurements.ts | 3 + src/lib/validations/source-priority.ts | 37 +++++ 15 files changed, 245 insertions(+), 23 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d11197593..866b0ad4e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4084,6 +4084,24 @@ paths: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -4411,6 +4429,24 @@ paths: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP deviceTypePriority: type: object properties: @@ -9305,6 +9341,8 @@ components: enum: - weekly - biweekly + ambientSuggestions: + type: boolean measurementReminder: type: object properties: @@ -13636,6 +13674,24 @@ components: minimum: 0 maximum: 9007199254740991 description: Nights still needed before the debt is asserted (0 when ready). + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. + `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would + be a device-native debt. Clients explain the figure when it is `COMPUTED`. required: - state - debtMinutes @@ -13643,6 +13699,7 @@ components: - nightsCounted - windowNights - nightsUntilReady + - source additionalProperties: false chronotype: type: object @@ -16518,6 +16575,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -16845,6 +16920,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object @@ -17218,6 +17311,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -17545,6 +17656,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object diff --git a/messages/de.json b/messages/de.json index 9307de5f8..931ed18f4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2691,7 +2691,8 @@ "caption": "Kumuliertes Defizit der letzten {nights} Nächte", "clearValue": "Ausgeglichen", "clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte", - "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz." + "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.", + "computedInfo": "Das ist HealthLogs eigene Schätzung: eine gleitende Bilanz aus Bedarf minus Schlaf über die letzten Nächte, die Aufholschlaf wieder abbaut. Sie kann vom nativen Schlafdefizit eines Wearables abweichen, das ein eigenes Bedarfsmodell nutzt." }, "average": { "title": "Durchschnitt pro Nacht", @@ -4043,7 +4044,8 @@ "skinTemperature": "Hauttemperatur", "respiratoryRate": "Atemfrequenz", "recovery": "Erholung", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Schlafdefizit" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/en.json b/messages/en.json index 716477da3..459d7d6b3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2691,7 +2691,8 @@ "caption": "Cumulative shortfall over the last {nights} nights", "clearValue": "All caught up", "clearCaption": "No accumulated shortfall over the last {nights} nights", - "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance." + "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance.", + "computedInfo": "This is HealthLog's own estimate: a rolling balance of need minus sleep over recent nights, with catch-up sleep paying it down. It can differ from a wearable's native sleep-debt figure, which uses its own need model." }, "average": { "title": "Average per night", @@ -4043,7 +4044,8 @@ "skinTemperature": "Skin temperature", "respiratoryRate": "Respiratory rate", "recovery": "Recovery", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Sleep debt" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/es.json b/messages/es.json index fe9e716ea..6b7699af0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2691,7 +2691,8 @@ "caption": "Déficit acumulado de las últimas {nights} noches", "clearValue": "Al día", "clearCaption": "Sin déficit acumulado en las últimas {nights} noches", - "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable." + "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable.", + "computedInfo": "Esta es la estimación propia de HealthLog: un balance móvil de la necesidad menos el sueño de las últimas noches, que el sueño de recuperación va reduciendo. Puede diferir de la cifra de deuda de sueño nativa de un wearable, que usa su propio modelo de necesidad." }, "average": { "title": "Media por noche", @@ -4043,7 +4044,8 @@ "skinTemperature": "Temperatura de la piel", "respiratoryRate": "Frecuencia respiratoria", "recovery": "Recuperación", - "stress": "Estrés" + "stress": "Estrés", + "sleepDebt": "Deuda de sueño" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/fr.json b/messages/fr.json index 46874950c..c552c4baa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2691,7 +2691,8 @@ "caption": "Déficit cumulé sur les {nights} dernières nuits", "clearValue": "À jour", "clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits", - "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable." + "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.", + "computedInfo": "Il s'agit de l'estimation propre à HealthLog : un solde glissant entre le besoin et le sommeil des dernières nuits, que le sommeil de récupération réduit. Elle peut différer de la dette de sommeil native d'un wearable, qui utilise son propre modèle de besoin." }, "average": { "title": "Moyenne par nuit", @@ -4043,7 +4044,8 @@ "skinTemperature": "Température cutanée", "respiratoryRate": "Fréquence respiratoire", "recovery": "Récupération", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Dette de sommeil" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/it.json b/messages/it.json index 8eda2e620..a8881791b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2691,7 +2691,8 @@ "caption": "Deficit cumulato nelle ultime {nights} notti", "clearValue": "In pari", "clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti", - "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile." + "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.", + "computedInfo": "Questa è la stima propria di HealthLog: un bilancio mobile tra fabbisogno e sonno delle ultime notti, che il sonno di recupero riduce. Può differire dal dato di debito di sonno nativo di un dispositivo indossabile, che usa un proprio modello di fabbisogno." }, "average": { "title": "Media per notte", @@ -4043,7 +4044,8 @@ "skinTemperature": "Temperatura cutanea", "respiratoryRate": "Frequenza respiratoria", "recovery": "Recupero", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Debito di sonno" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/messages/pl.json b/messages/pl.json index 9bbc6dc27..e92f79e01 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2691,7 +2691,8 @@ "caption": "Skumulowany niedobór z ostatnich {nights} nocy", "clearValue": "Na bieżąco", "clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach", - "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans." + "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.", + "computedInfo": "To własne oszacowanie HealthLog: ruchomy bilans potrzeby snu minus sen z ostatnich nocy, który sen nadrabiający zmniejsza. Może różnić się od natywnego wskaźnika długu snu z urządzenia, które korzysta z własnego modelu zapotrzebowania." }, "average": { "title": "Średnia na noc", @@ -4043,7 +4044,8 @@ "skinTemperature": "Temperatura skóry", "respiratoryRate": "Częstość oddechów", "recovery": "Regeneracja", - "stress": "Stres" + "stress": "Stres", + "sleepDebt": "Dług snu" }, "sourceLabels": { "WITHINGS": "Withings", diff --git a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx index 6216b89bb..a96866b28 100644 --- a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx +++ b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx @@ -39,6 +39,7 @@ const READY_DTO: SleepRhythmDto = { nightsCounted: 10, windowNights: 14, nightsUntilReady: 0, + source: "COMPUTED", }, chronotype: { state: "ready", diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index 6dfe7a9ca..8638beae8 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -23,6 +23,7 @@ const PARTIAL_DEBT: SleepDebtDto = { nightsCounted: 3, windowNights: 5, nightsUntilReady: 1, + source: "COMPUTED", }; const READY_DEBT: SleepDebtDto = { @@ -32,6 +33,7 @@ const READY_DEBT: SleepDebtDto = { nightsCounted: 5, windowNights: 5, nightsUntilReady: 0, + source: "COMPUTED", }; const LEARNING_CHRONO: ChronotypeDto = { diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx index 5ed46274a..a5ff44d3c 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx @@ -47,6 +47,7 @@ const READY_DTO: SleepRhythmDto = { nightsCounted: 5, windowNights: 5, nightsUntilReady: 0, + source: "COMPUTED", }, chronotype: { state: "ready", diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 4b5954654..db4d329cb 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -4,6 +4,7 @@ import { Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { InfoHint } from "@/components/ui/info-hint"; import { LearningGate } from "@/components/ui/learning-gate"; import type { SleepDebtDto } from "./use-sleep-rhythm"; @@ -37,6 +38,13 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { {t("insights.sleep.debt.title")} + {/* v1.25.0 — when the active source on the user's sleep-debt ladder + is our own COMPUTED engine (every user today, until a provider + ships a native debt), explain what the figure means and that it + differs from a wearable's native number. */} + {debt.source === "COMPUTED" ? ( + + ) : null} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index c07fa2ae4..e2a1d78e3 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -89,6 +89,8 @@ const METRIC_LABEL_KEYS: Record = { recovery: "settings.sections.sources.metrics.recovery", // v1.18.10 I-5 — native-vs-derived stress ladder. stress: "settings.sections.sources.metrics.stress", + // v1.25.0 — native-vs-computed sleep-debt ladder. + sleepDebt: "settings.sections.sources.metrics.sleepDebt", }; const SOURCE_LABEL_KEYS: Record = { diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts index e9a2eade5..e63ba97be 100644 --- a/src/lib/insights/derived/sleep-rhythm.ts +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -34,10 +34,17 @@ * window. The DTO is the shared wire shape the route, the dashboard summary, * and the iOS serializer all emit identically. */ -import type { MeasurementType } from "@/generated/prisma/client"; +import type { + MeasurementSource, + MeasurementType, +} from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { + parseSourcePriority, + resolveSleepDebtSource, +} from "@/lib/validations/source-priority"; import { loadBaselineProfile } from "./baseline"; import { reconstructNights, sleepNeedMinutes } from "./sleep-score"; import { @@ -54,14 +61,14 @@ import { const MS_PER_DAY = 24 * 60 * 60 * 1000; /** - * Trailing read window in days. The sleep-debt rolling window is 14 nights and - * chronotype wants a few weeks of free-day samples; 42 days (six weeks) gives - * the debt window full coverage and ~12 weekend nights for a stable MSF while - * staying a bounded per-stage read. + * Trailing read window in days. The sleep-debt rolling window is short (a few + * recent nights) and chronotype wants a few weeks of free-day samples; 42 days + * (six weeks) gives both windows full coverage and ~12 weekend nights for a + * stable MSF while staying a bounded per-stage read. */ const DEFAULT_WINDOW_DAYS = 42; -/** Wire DTO for the cumulative sleep debt — see `SleepDebtResult`. */ +/** Wire DTO for the rolling sleep debt — see `SleepDebtResult`. */ export interface SleepDebtDto { state: "partial" | "ready"; debtMinutes: number; @@ -69,6 +76,13 @@ export interface SleepDebtDto { nightsCounted: number; windowNights: number; nightsUntilReady: number; + /** + * v1.25.0 — the active source the debt figure is resolved FROM, picked off + * the user's `sleepDebt` source ladder. `COMPUTED` means HealthLog's own + * rolling-balance estimate (the only producer today); a provider value would + * mean a device-native debt. The UI explains the figure when it is COMPUTED. + */ + source: MeasurementSource; } /** @@ -169,7 +183,10 @@ export function defaultDayType(night: string): "work" | "free" { return weekday === 0 || weekday === 6 ? "free" : "work"; } -function toDebtDto(r: SleepDebtResult): SleepDebtDto { +function toDebtDto( + r: SleepDebtResult, + source: MeasurementSource, +): SleepDebtDto { return { state: r.state, debtMinutes: r.debtMinutes, @@ -177,6 +194,7 @@ function toDebtDto(r: SleepDebtResult): SleepDebtDto { nightsCounted: r.nightsCounted, windowNights: r.windowNights, nightsUntilReady: r.nightsUntilReady, + source, }; } @@ -237,6 +255,10 @@ const CHRONOTYPE_WINDOW_NIGHTS = 42; export function computeSleepRhythmFromNights( nights: readonly RhythmNight[], needMinutes: number, + // v1.25.0 — the resolved sleep-debt source off the user's ladder. Defaults to + // COMPUTED (the only producer today) so the dashboard + Coach snapshot + // callers, which always surface our own estimate, need not thread it. + sleepDebtSource: MeasurementSource = "COMPUTED", ): SleepRhythmDto { // Sort ascending by wake-day key so "trailing N" is well-defined regardless // of the caller's input order, then keep only scorable nights. @@ -244,8 +266,8 @@ export function computeSleepRhythmFromNights( .filter((n) => n.asleepMinutes > 0) .sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0)); - // Debt self-caps to its own 14-night window internally; pass every scorable - // night and let the module slice. + // Debt self-caps to its own short rolling window internally; pass every + // scorable night and let the module slice. const debtNights: SleepDebtNight[] = scorable.map((n) => ({ night: n.night, asleepMinutes: n.asleepMinutes, @@ -273,7 +295,7 @@ export function computeSleepRhythmFromNights( const averagePerNight = computeAverageSleep(scorable); return { - sleepDebt: toDebtDto(sleepDebt), + sleepDebt: toDebtDto(sleepDebt, sleepDebtSource), chronotype: toChronotypeDto(chronotype), averagePerNight, }; @@ -324,5 +346,10 @@ export async function buildSleepRhythm( const nights = reconstructNights(rows, tz, priorityJson); const needMinutes = sleepNeedMinutes(profile.ageYears); - return computeSleepRhythmFromNights(nights, needMinutes); + // v1.25.0 — resolve the active sleep-debt source off the user's ladder so the + // DTO (and the Sleep page) can name it and explain the COMPUTED estimate. + const sleepDebtSource = resolveSleepDebtSource( + parseSourcePriority(priorityJson), + ); + return computeSleepRhythmFromNights(nights, needMinutes, sleepDebtSource); } diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 0c2a70172..650d157f8 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -292,6 +292,9 @@ const sleepDebtResource = z.object({ .describe( "Nights still needed before the debt is asserted (0 when ready).", ), + source: measurementSourceEnum.describe( + "v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would be a device-native debt. Clients explain the figure when it is `COMPUTED`.", + ), }); const chronotypeBandEnum = z.enum([ diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index b54781693..e1d7614ab 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -138,6 +138,12 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [ // collapsing to the alphabetical rank-90 tiebreak. Single-producer today, // so the ladder is a no-op until the second producer lands. "stress", + // v1.25.0 — native-vs-computed SLEEP DEBT. HealthLog computes a COMPUTED + // rolling-balance debt off the canonical nights; a provider that ships a + // native sleep-debt balance (none today) is the deferred second source. + // Laddering it now keeps the picker deterministic — and the choice user- + // configurable, not WHOOP-hardcoded — the instant a native producer lands. + "sleepDebt", ] as const; export type SourcePriorityMetricKey = @@ -309,6 +315,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // COMPUTED HRV-derived proxy; the proxy is the fallback for users without a // device-native stress signal. Single-producer today. stress: ["OURA", "COMPUTED"], + // v1.25.0 — native-vs-computed sleep debt. A device-native sleep-debt + // balance (none ships one today) would outrank HealthLog's COMPUTED + // rolling-balance debt; the COMPUTED engine is the fallback every user lands + // on now. Mirrors the recovery ladder's native-above-computed ordering. + sleepDebt: ["WHOOP", "OURA", "POLAR", "COMPUTED"], }; /** @@ -462,6 +473,32 @@ function buildResolved( return resolved; } +/** + * v1.25.0 — sources we can currently surface a SLEEP-DEBT figure from. Only + * the COMPUTED engine produces one today; a provider that ships a native + * sleep-debt balance is added here (and ingested) and then wins per the + * user's `sleepDebt` ladder with no change at the call site. + */ +export const SLEEP_DEBT_SUPPLIERS: ReadonlySet< + z.infer +> = new Set(["COMPUTED"]); + +/** + * v1.25.0 — resolve the active sleep-debt source for a user: walk the + * `sleepDebt` ladder and return the highest-ranked source we can actually + * produce a debt for. Falls back to COMPUTED (always a supplier) when the + * ladder lists no available native source — which is every user today, so the + * UI knows to explain that the figure is our own computed estimate. + */ +export function resolveSleepDebtSource( + resolved: ResolvedSourcePriority, +): z.infer { + for (const source of resolved.metricPriority.sleepDebt) { + if (SLEEP_DEBT_SUPPLIERS.has(source)) return source; + } + return "COMPUTED"; +} + /** * v1.4.25 W8c — runtime device-type checker for ingest paths. Returns * a `DeviceType` for any input the iOS app or Withings webhook might