From 9d22412caf03b1c520eda713f971caae7d49a3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 15:38:35 +0200 Subject: [PATCH 1/6] fix(coach): size the example opener to its text and keep it single-line The ambient example opener was stretched to the full composer width, leaving a large empty tail that read as broken. Size it to its text instead (capped at the full width) and guarantee a single line: the question truncates with an ellipsis on a narrow phone rather than wrapping or forcing a horizontal scroll. --- .../__tests__/scope-hint-badge.test.tsx | 17 +++++++++++++++-- .../insights/coach-panel/scope-hint-badge.tsx | 19 +++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx index 268bce7a0..86003e2b3 100644 --- a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx +++ b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx @@ -112,7 +112,7 @@ describe("", () => { expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"'); }); - it("lets the seeded opener fill its column (no max-w-md cap)", () => { + it("sizes the seeded opener to its text and keeps it single-line", () => { const html = render( ", () => { onDismiss={() => {}} />, ); - // The opener grows to the composer column width; the old narrow cap is gone. + const seedTag = html.match( + /]*data-slot="coach-scope-hint-seed"[^>]*>/, + ); + expect(seedTag).not.toBeNull(); + // v1.25.0 — intrinsic width, not stretched to the composer column: the + // opener grows to its text and caps at the full width. The old narrow cap + // and the full-width stretch are both gone. expect(html).not.toContain("max-w-md"); + // Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not + // the old `flex-1` / `w-full` full-width stretch. + expect(seedTag?.[0]).toContain("inline-flex"); + expect(seedTag?.[0]).toContain("max-w-full"); + expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/); + // The question span truncates with an ellipsis rather than wrapping. + expect(html).toContain("truncate"); }); }); diff --git a/src/components/insights/coach-panel/scope-hint-badge.tsx b/src/components/insights/coach-panel/scope-hint-badge.tsx index 370231e9a..8fbc980ec 100644 --- a/src/components/insights/coach-panel/scope-hint-badge.tsx +++ b/src/components/insights/coach-panel/scope-hint-badge.tsx @@ -91,28 +91,31 @@ export function ScopeHintBadge({ {/* The tappable seed question — the data-aware opener. A real button so - keyboard + screen-reader users get the same affordance. The opener - fills the column (= the composer's max-width) so it reads as one line - with the field below; the seeded variant adds a dismiss control at the - trailing edge, mirroring the composer's send affordance. */} -
+ keyboard + screen-reader users get the same affordance. v1.25.0 — the + opener sizes to its TEXT (intrinsic width, centred) instead of being + stretched to the full composer width, which left a large empty tail + that read as broken. It is guaranteed single-line: the question + truncates with an ellipsis on a narrow phone rather than wrapping or + forcing a horizontal scroll. The seeded variant keeps the dismiss + control at the trailing edge. */} +
- ); + ) : null; // v1.18.11 — the send / stop control. Same const-lift rationale as the // mic: one definition, two mount points. From fba8553192cfb353105131260c7320e5504d8981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 15:40:47 +0200 Subject: [PATCH 3/6] fix(insights): remove the trajectory continuation tile from metric pages The short-horizon trajectory tile read as a wall of text with no clear value on the metric pages. Drop it from the weight page and the shared HealthKit metric page (the opt-in forecast slot is removed entirely, not left dead), and delete the now-orphaned tile + fan components. The trajectory engine stays for the Coach narrative; only the metric-page surfacing is removed. --- src/app/insights/weight/page.tsx | 9 - .../insights/derived/trajectory-fan.tsx | 79 ----- .../derived/trajectory-forecast-card.tsx | 331 ------------------ .../insights/healthkit-metric-page.tsx | 24 -- 4 files changed, 443 deletions(-) delete mode 100644 src/components/insights/derived/trajectory-fan.tsx delete mode 100644 src/components/insights/derived/trajectory-forecast-card.tsx diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index b621a2600..453a2e3e4 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -18,7 +18,6 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card"; import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands"; /** @@ -115,14 +114,6 @@ export default function InsightsGewichtPage() { - - {/* v1.12.0 — Weight owns the weight × weekday correlation (relocated off the overview onto its metric page). */} diff --git a/src/components/insights/derived/trajectory-fan.tsx b/src/components/insights/derived/trajectory-fan.tsx deleted file mode 100644 index d3f8b11ad..000000000 --- a/src/components/insights/derived/trajectory-fan.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { - ResponsiveContainer, - ComposedChart, - Area, - Line, - XAxis, - YAxis, - ReferenceLine, -} from "recharts"; - -/** - * v1.12.1 — the Recharts fan band of the trajectory forecast card, - * split into its own module so it can be `next/dynamic`-deferred. - * - * `TrajectoryForecastCard` is mounted by the shared `HealthKitMetricPage` - * on every trajectory-eligible metric page; before this split it pulled - * Recharts into the page's initial chunk eagerly (the main metric chart - * is already deferred via `HealthChartDynamic`). Isolating the only - * Recharts-touching subtree here lets the card chrome render without the - * chart library and lazy-load this chunk only when there's a projection - * to draw — keeping the chart visually identical (same ComposedChart / - * Area / Line / ReferenceLine geometry, same `isAnimationActive={false}`). - */ - -export interface TrajectoryFanPoint { - date: string; - projected: number; - /** Stacked transparent base (= bandLow). */ - base: number; - /** Shaded band height (= bandHigh − bandLow). */ - range: number; -} - -export function TrajectoryFan({ - points, - color, -}: { - points: TrajectoryFanPoint[]; - color: string; -}) { - return ( - - - - - - - - - - - ); -} diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx deleted file mode 100644 index d9cc97903..000000000 --- a/src/components/insights/derived/trajectory-forecast-card.tsx +++ /dev/null @@ -1,331 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import dynamic from "next/dynamic"; -import { TrendingUp, TrendingDown, Minus } from "lucide-react"; - -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { LearningGate } from "@/components/ui/learning-gate"; -import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; -import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; -import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; -import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric"; - -// v1.12.1 — defer the Recharts fan band so the card chrome (and the whole -// HealthKit metric page that always mounts this card on a trajectory- -// eligible type) no longer pulls Recharts into the initial chunk. The -// loading shell is a plain box that fills the same fixed-height band the -// chart paints, so there is no layout shift when the chunk arrives. -const TrajectoryFanDynamic = dynamic( - () => - import("@/components/insights/derived/trajectory-fan").then((mod) => ({ - default: mod.TrajectoryFan, - })), - { - ssr: false, - loading: () =>
, - }, -); -// Type-only — the compute payload never drags the server graph into the -// client bundle (the v1.9.0 lesson, mirrored across the derived surfaces). -import type { TrajectoryValue } from "@/lib/insights/derived/trajectory"; -import type { DerivedProvenance } from "@/lib/insights/derived/types"; - -/** - * v1.11.0 (Epic B, Pillar 3) — the short-horizon projection card on the - * metric detail page. - * - * Surfaces the deterministic `TRAJECTORY` derived metric as a small band - * chart: the recent observed line, then the projected line (dashed) with a - * widening prediction-interval fan. The fan VISIBLY widens with the horizon - * — that is the uncertainty communication, no words needed. The number is - * computed server-side; this card only draws it. It is explicitly labelled - * a PROJECTION ("if this pattern continues"), never a prediction or a dated - * event, and the cited method (OLS + prediction interval) rides the ⓘ - * provenance affordance. - * - * Below the engine's R² / history / staleness floor the engine returns - * `insufficient`, and the card renders the same calm "not enough of a trend - * to project yet" + coverage state every derived surface uses — never a weak - * line, never a blank. - * - * The card mounts as a sibling beneath the main metric chart (mirroring - * `MetricStatusCard`), so the main chart's visual identity is untouched. - */ - -const TRAJECTORY_METRIC = "TRAJECTORY"; - -/** - * Mirror of `TRAJECTORY_MIN_HISTORY_DAYS` (`src/lib/insights/derived/trajectory.ts`). - * Re-declared as a client-side literal rather than value-imported because the - * engine module pulls the rollup/baseline server graph in at its top level — - * importing the constant would drag that graph into the client bundle (the same - * reason `TrajectoryValue` is a type-only import). Keep this in lockstep with - * the engine; the unit test pins both at 14. - */ -const TRAJECTORY_MIN_HISTORY_DAYS = 14; - -/** A single value-scale used to render stored values in the display unit. */ -function scaled(value: number, scale: number): number { - return value * scale; -} - -interface TrajectoryForecastCardProps { - /** The MeasurementType to project (the page's canonical type). */ - type: string; - /** Display unit suffix (e.g. "kg"). */ - unit?: string; - /** Display scale folded into every rendered value (defaults to 1). */ - valueScale?: number; - /** Canonical chart colour for the metric's line. */ - color?: string; - /** Gate the underlying derived read (e.g. on the auth/empty flag). */ - enabled?: boolean; - /** - * v1.12.0 — density by render context. Sub-pages pass `compact` so the - * card sheds the `min-h-48` floor + drops to a shorter chart and a tighter - * no-trend state (a too-tall "1 of 1" empty band was the maintainer flag). - * The Insights OVERVIEW grid omits it so the tile KEEPS its current size - * for grid symmetry — the default is the unchanged, roomier geometry. - */ - compact?: boolean; - className?: string; -} - -/** The provenance ⓘ explainer for the projection, wired from the map. */ -function TrajectoryProvenance({ - provenance, -}: { - provenance: DerivedProvenance; -}) { - const { t } = useTranslations(); - const meta = METRIC_PROVENANCE.TRAJECTORY; - const method = ( - <> - {meta.caveatKey ? ( - - {t(meta.caveatKey)} - - ) : null} - {t(meta.methodKey)} - - ); - return ( - - ); -} - -/** Card shell — uppercase label + provenance affordance + body children. */ -function CardShell({ - provenance, - compact = false, - children, -}: { - provenance?: DerivedProvenance; - compact?: boolean; - children: React.ReactNode; -}) { - const { t } = useTranslations(); - return ( -
-
- - {t("insights.derived.trajectory.cardTitle")} - - {provenance ? : null} -
- {children} -
- ); -} - -/** CLS-safe skeleton matching the resolved card geometry. */ -function CardSkeleton({ compact = false }: { compact?: boolean }) { - return ( - )} + {/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the + push-nudge master switch above (a user can keep nudges but silence + the example prompts), so it sits outside the `enabled` gate and rides + the same `coach` prefs blob. */} +
+
+

+ {t("notifications.coachNudge.suggestionsLabel")} +

+

+ {t("notifications.coachNudge.suggestionsDesc")} +

+
+ + patchCoach({ ambientSuggestions: next }, null) + } + disabled={!isAuthenticated || saving} + aria-label={t("notifications.coachNudge.suggestionsAria")} + /> +
{msg && (

+ apiGet("/api/auth/me/notification-prefs"), + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + return data?.coach?.ambientSuggestions ?? true; +} diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index 813f2c8ac..ee37ed340 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -109,6 +109,7 @@ describe("parseNotificationPrefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -132,6 +133,7 @@ describe("parseNotificationPrefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -157,6 +159,7 @@ describe("parseNotificationPrefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -198,6 +201,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -227,6 +231,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -253,6 +258,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -279,6 +285,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -306,6 +313,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 0f405c708..26abf53ab 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -125,6 +125,14 @@ const coachPrefsSchema = z nudgeRoutine: z.boolean(), /** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */ nudgeFrequency: z.enum(["weekly", "biweekly"]), + /** + * v1.25.0 — opt-out for the proactive ambient SUGGESTIONS surfaced in the + * UI (the daily seeded example opener on the Coach hero + the "try asking" + * prompt chips). Default ON. Separate from `nudgesEnabled` (the push-nudge + * master switch) so a user can keep the nudges but silence the example + * prompts, or vice versa. + */ + ambientSuggestions: z.boolean(), }) .partial(); @@ -200,6 +208,11 @@ export interface NotificationPrefs { nudgeRoutine: boolean; /** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */ nudgeFrequency: "weekly" | "biweekly"; + /** + * v1.25.0 — proactive ambient example suggestions in the UI (seeded + * opener + suggested-prompt chips). Default `true`. + */ + ambientSuggestions: boolean; }; measurementReminder: { /** @@ -256,6 +269,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -447,6 +461,16 @@ export interface CoachNudgePrefs { minIntervalDays: number; } +/** + * v1.25.0 — the single source every proactive ambient SUGGESTION surface + * consults (the daily seeded example opener on the Coach hero + the + * suggested-prompt chips). Default `true`; a null / drifted row resolves to + * the documented default so the suggestions work out of the box. + */ +export function resolveCoachAmbientSuggestionsEnabled(raw: unknown): boolean { + return parseNotificationPrefs(raw).coach.ambientSuggestions; +} + export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs { const coach = parseNotificationPrefs(raw).coach; return { 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 6/6] 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