mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
c862b4880f
commit
d570d558fa
15 files changed
+245
-23
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user