feat(insights): make sleep debt a configurable source ladder with a computed explainer

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:04:22 +02:00
1 parent c862b4880f
commit d570d558fa
15 files changed
+245 -23

No files matched your search

+129
View File
@@ -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
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
@@ -39,6 +39,7 @@ const READY_DTO: SleepRhythmDto = {
nightsCounted: 10,
windowNights: 14,
nightsUntilReady: 0,
source: "COMPUTED",
},
chronotype: {
state: "ready",
@@ -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 = {
@@ -47,6 +47,7 @@ const READY_DTO: SleepRhythmDto = {
nightsCounted: 5,
windowNights: 5,
nightsUntilReady: 0,
source: "COMPUTED",
},
chronotype: {
state: "ready",
@@ -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 }) {
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
{/* 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" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
) : null}
</div>
</CardHeader>
<CardContent>
@@ -89,6 +89,8 @@ const METRIC_LABEL_KEYS: Record<SourcePriorityMetricKey, string> = {
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<string, string> = {
+38 -11
View File
@@ -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);
}
+3
View File
@@ -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([
+37
View File
@@ -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<MetricPriority> = {
// 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<typeof measurementSourceEnum>
> = 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<typeof measurementSourceEnum> {
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