+ 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. */}
+
);
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/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx
index 61e2fc8d1..9cc6705d1 100644
--- a/src/components/settings/coach-nudge-card.tsx
+++ b/src/components/settings/coach-nudge-card.tsx
@@ -33,6 +33,7 @@ interface CoachPrefsShape {
nudgeVitals: boolean;
nudgeRoutine: boolean;
nudgeFrequency: "weekly" | "biweekly";
+ ambientSuggestions: boolean;
}
interface NotificationPrefsShape {
@@ -45,6 +46,7 @@ const COACH_PREF_DEFAULTS: CoachPrefsShape = {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
+ ambientSuggestions: true,
};
const GROUP_FIELDS = [
@@ -254,6 +256,28 @@ export function CoachNudgeCard({
)}
+ {/* 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 && (
= {
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/hooks/use-coach-ambient-suggestions.ts b/src/hooks/use-coach-ambient-suggestions.ts
new file mode 100644
index 000000000..1882488c4
--- /dev/null
+++ b/src/hooks/use-coach-ambient-suggestions.ts
@@ -0,0 +1,34 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { queryKeys } from "@/lib/query-keys";
+import { apiGet } from "@/lib/api/api-fetch";
+import { useAuth } from "@/hooks/use-auth";
+
+/**
+ * v1.25.0 — the single client-side gate for proactive ambient Coach
+ * SUGGESTIONS (today: the daily seeded example opener on the Coach hero).
+ *
+ * Reads the per-user opt-out from the shared notification-prefs blob
+ * (`coach.ambientSuggestions`). Default ON: an unauthenticated read, a
+ * still-loading query, or a missing field all resolve to `true` so the
+ * suggestions show out of the box and only hide once the user explicitly turns
+ * them off in Settings. Shares the `authNotificationPrefs` query key with the
+ * settings card, so toggling the switch there flips this read in lockstep.
+ */
+interface AmbientSuggestionsPrefsShape {
+ coach?: { ambientSuggestions?: boolean };
+}
+
+export function useCoachAmbientSuggestionsEnabled(): boolean {
+ const { isAuthenticated } = useAuth();
+ const { data } = useQuery({
+ queryKey: queryKeys.authNotificationPrefs(),
+ queryFn: async () =>
+ apiGet("/api/auth/me/notification-prefs"),
+ enabled: isAuthenticated,
+ staleTime: 5 * 60 * 1000,
+ });
+ return data?.coach?.ambientSuggestions ?? true;
+}
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/__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 {
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