mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -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