Merge branch 'feat/v125-coach-insights-rest' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:10:48 +02:00
commit 65f1358179
30 files changed
+512 -602

No files matched your search

+129
View File
@@ -4096,6 +4096,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:
@@ -4423,6 +4441,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:
@@ -9412,6 +9448,8 @@ components:
enum:
- weekly
- biweekly
ambientSuggestions:
type: boolean
measurementReminder:
type: object
properties:
@@ -13773,6 +13811,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
@@ -13780,6 +13836,7 @@ components:
- nightsCounted
- windowNights
- nightsUntilReady
- source
additionalProperties: false
chronotype:
type: object
@@ -16655,6 +16712,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:
@@ -16982,6 +17057,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
@@ -17355,6 +17448,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:
@@ -17682,6 +17793,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
+8 -3
View File
@@ -2697,7 +2697,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",
@@ -4049,7 +4050,8 @@
"skinTemperature": "Hauttemperatur",
"respiratoryRate": "Atemfrequenz",
"recovery": "Erholung",
"stress": "Stress"
"stress": "Stress",
"sleepDebt": "Schlafdefizit"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -6118,7 +6120,10 @@
"frequencyAria": "Häufigkeit der Coach-Hinweise wählen",
"enabledToast": "Proaktive Coach-Hinweise aktiviert.",
"disabledToast": "Proaktive Coach-Hinweise deaktiviert.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.",
"suggestionsLabel": "Beispielvorschläge",
"suggestionsDesc": "Zeigt auf dem Coach-Bildschirm gelegentlich eine fertige Beispielfrage.",
"suggestionsAria": "Beispiel-Coach-Vorschläge umschalten"
},
"lowStock": {
"title": "Vorratswarnung",
+8 -3
View File
@@ -2697,7 +2697,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",
@@ -4049,7 +4050,8 @@
"skinTemperature": "Skin temperature",
"respiratoryRate": "Respiratory rate",
"recovery": "Recovery",
"stress": "Stress"
"stress": "Stress",
"sleepDebt": "Sleep debt"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -6118,7 +6120,10 @@
"frequencyAria": "Select nudge frequency",
"enabledToast": "Proactive Coach nudges enabled.",
"disabledToast": "Proactive Coach nudges disabled.",
"saveError": "Could not save. Please try again later."
"saveError": "Could not save. Please try again later.",
"suggestionsLabel": "Example suggestions",
"suggestionsDesc": "Show the occasional ready-made example question on the Coach screen.",
"suggestionsAria": "Toggle example Coach suggestions"
},
"lowStock": {
"title": "Low-stock alert",
+8 -3
View File
@@ -2697,7 +2697,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",
@@ -4049,7 +4050,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",
@@ -6118,7 +6120,10 @@
"statusOff": "Desactivado",
"enabledToast": "Avisos proactivos del Coach activados.",
"disabledToast": "Avisos proactivos del Coach desactivados.",
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde."
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde.",
"suggestionsLabel": "Sugerencias de ejemplo",
"suggestionsDesc": "Muestra de vez en cuando una pregunta de ejemplo en la pantalla del Coach.",
"suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach"
},
"lowStock": {
"title": "Aviso de existencias bajas",
+8 -3
View File
@@ -2697,7 +2697,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",
@@ -4049,7 +4050,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",
@@ -6118,7 +6120,10 @@
"statusOff": "Désactivé",
"enabledToast": "Rappels proactifs du Coach activés.",
"disabledToast": "Rappels proactifs du Coach désactivés.",
"saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard."
"saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard.",
"suggestionsLabel": "Suggestions d'exemples",
"suggestionsDesc": "Affiche de temps en temps une question d'exemple prête à l'emploi sur l'écran du Coach.",
"suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach"
},
"lowStock": {
"title": "Alerte de stock faible",
+8 -3
View File
@@ -2697,7 +2697,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",
@@ -4049,7 +4050,8 @@
"skinTemperature": "Temperatura cutanea",
"respiratoryRate": "Frequenza respiratoria",
"recovery": "Recupero",
"stress": "Stress"
"stress": "Stress",
"sleepDebt": "Debito di sonno"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -6118,7 +6120,10 @@
"statusOff": "Disattivato",
"enabledToast": "Avvisi proattivi del Coach attivati.",
"disabledToast": "Avvisi proattivi del Coach disattivati.",
"saveError": "Impossibile salvare. Riprova più tardi."
"saveError": "Impossibile salvare. Riprova più tardi.",
"suggestionsLabel": "Suggerimenti di esempio",
"suggestionsDesc": "Mostra di tanto in tanto una domanda di esempio pronta all'uso nella schermata del Coach.",
"suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach"
},
"lowStock": {
"title": "Avviso scorte basse",
+8 -3
View File
@@ -2697,7 +2697,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",
@@ -4049,7 +4050,8 @@
"skinTemperature": "Temperatura skóry",
"respiratoryRate": "Częstość oddechów",
"recovery": "Regeneracja",
"stress": "Stres"
"stress": "Stres",
"sleepDebt": "Dług snu"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -6118,7 +6120,10 @@
"statusOff": "Wyłączone",
"enabledToast": "Proaktywne podpowiedzi Coacha włączone.",
"disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.",
"saveError": "Nie udało się zapisać. Spróbuj ponownie później."
"saveError": "Nie udało się zapisać. Spróbuj ponownie później.",
"suggestionsLabel": "Przykładowe sugestie",
"suggestionsDesc": "Czasami pokazuje gotowe przykładowe pytanie na ekranie Coacha.",
"suggestionsAria": "Przełącz przykładowe sugestie Coacha"
},
"lowStock": {
"title": "Alert niskiego zapasu",
@@ -102,6 +102,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -137,6 +138,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -174,6 +176,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -222,6 +225,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -246,6 +250,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -274,6 +279,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -294,6 +300,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -367,6 +374,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -405,6 +413,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -429,6 +438,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
@@ -472,6 +482,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
},
measurementReminder: {
clientManaged: false,
-9
View File
@@ -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() {
<MetricTargetSummary slug="weight" />
<TrajectoryForecastCard
type="WEIGHT"
unit="kg"
color="#bd93f9"
enabled={!isEmpty}
compact
/>
{/* v1.12.0 — Weight owns the weight × weekday correlation (relocated
off the overview onto its metric page). */}
<MetricCorrelationCard slug="weight" />
@@ -24,21 +24,16 @@ describe("<CoachInput>", () => {
expect(html).not.toContain("Coach replies are generated");
});
it("renders the mic disabled with an unsupported tooltip in SSR markup", () => {
// v1.18.10 (W4) — the mic always renders so the affordance stays
// discoverable. SSR (and any browser without the Web Speech API) shows
// it DISABLED with an explanatory tooltip rather than vanishing or
// sitting as a dead control. A post-hydration effect re-enables it once
// `SpeechRecognition` is confirmed present.
it("hides the mic in SSR markup (dictation is client-and-secure-context only)", () => {
// v1.25.0 — the mic only mounts once the client confirms the Web Speech
// API exists AND the page runs in a secure context. SSR (and any
// unsupported / non-secure environment) renders NO mic at all, so the
// composer never ships an advertised-but-erroring control. The earlier
// "render it disabled" affordance is gone.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
const micTag = html.match(/<button[^>]*data-slot="coach-input-mic"[^>]*>/);
expect(micTag).not.toBeNull();
expect(micTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
expect(micTag?.[0]).toContain('data-unsupported="true"');
// Tooltip explains why it is inert, sourced from i18n.
expect(html).toContain("Voice input is not supported in this browser");
expect(html).not.toContain('data-slot="coach-input-mic"');
});
it("renders the localised placeholder without the retired shortcut hint", () => {
@@ -232,8 +227,9 @@ describe("<CoachInput>", () => {
expect(html).not.toContain('data-slot="coach-input-hub"');
expect(html).not.toContain('data-slot="coach-input-actions"');
expect(html).not.toContain('data-slot="coach-input-settings"');
// Mic + send still render in the single-row layout.
expect(html).toContain('data-slot="coach-input-mic"');
// Send renders in the single-row layout. The mic is client-and-secure-
// context only, so it never appears in SSR markup.
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
});
@@ -256,8 +252,9 @@ describe("<CoachInput>", () => {
expect(html).toContain('data-slot="coach-input-actions"');
// The settings gear is gone from the composer entirely.
expect(html).not.toContain('data-slot="coach-input-settings"');
// Mic + send remain present on the same row.
expect(html).toContain('data-slot="coach-input-mic"');
// Send remains in the trailing cluster. The mic is client-and-secure-
// context only, so it never appears in SSR markup.
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
// The leading `+` precedes the textarea; the textarea precedes the send.
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
@@ -112,7 +112,7 @@ describe("<ScopeHintBadge>", () => {
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(
<ScopeHintBadge
variant="seeded"
@@ -122,8 +122,21 @@ describe("<ScopeHintBadge>", () => {
onDismiss={() => {}}
/>,
);
// The opener grows to the composer column width; the old narrow cap is gone.
const seedTag = html.match(
/<button[^>]*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");
});
});
@@ -40,6 +40,7 @@ import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
import { useResettableValue } from "./use-resettable-value";
import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions";
import {
useCoachConversation,
useCoachConversations,
@@ -516,8 +517,15 @@ export function CoachConversation({
return false;
}
});
// v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the
// seeded opener (and skips its fetch when off). The A2 launch scope below is
// NOT ambient — it reflects how the chat was opened — so it stays unaffected.
const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled();
const seededEnabled =
heroActive && a2Metric === null && !seededDismissedToday;
heroActive &&
a2Metric === null &&
!seededDismissedToday &&
ambientSuggestionsEnabled;
const { data: seeded } = useQuery({
queryKey: queryKeys.coachSeededQuestion(),
queryFn: async () =>
@@ -574,7 +582,11 @@ export function CoachConversation({
onSeed={seedComposer}
/>
);
} else if (seeded?.signal && !seededDismissedToday) {
} else if (
seeded?.signal &&
!seededDismissedToday &&
ambientSuggestionsEnabled
) {
// A3 — the notable derived signal. The label + opener are keyed on the
// signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel
// (future detector additions) skips the opener rather than guessing.
@@ -55,10 +55,13 @@ import { useTranslations } from "@/lib/i18n/context";
* transcripts append into the controlled `value`; the button toggles
* listening on/off and is fully keyboard- and screen-reader-accessible.
*
* v1.18.10 (W4): the mic always renders so the affordance is discoverable.
* On a browser without the Web Speech API (or during SSR) it renders
* DISABLED with an explanatory tooltip rather than vanishing or sitting as
* a dead control that silently does nothing on tap.
* v1.25.0: the mic only renders when dictation is genuinely usable —
* the Web Speech API exists AND the page runs in a secure context (the
* recogniser refuses to start over plain HTTP). On SSR, an unsupported
* browser (Firefox), a non-secure self-host, or after the user denies the
* mic permission, the control HIDES rather than sitting as an
* advertised-but-erroring affordance. A real failure mid-use surfaces a
* single quiet toast and then retires the button for the session.
*
* v1.18.11 (W11): the composer is the conversation's control hub on the
* full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a
@@ -193,15 +196,34 @@ function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null {
// mic at SSR) and the client snapshot reflects whether the constructor
// exists. The store never changes after hydration, so the subscribe
// callback is a no-op.
//
// v1.25.0 — the snapshot also requires a SECURE CONTEXT. `SpeechRecognition`
// throws / fires a `not-allowed` error the instant it starts over plain HTTP,
// so a LAN / Tailscale self-host on `http://` would otherwise advertise a mic
// that can never work. Gating support on `window.isSecureContext` hides it
// there cleanly instead.
const noopSubscribe = () => () => {};
function useVoiceSupported(): boolean {
return useSyncExternalStore(
noopSubscribe,
() => getSpeechRecognitionCtor() !== null,
() => getSpeechRecognitionCtor() !== null && window.isSecureContext,
() => false,
);
}
// v1.25.0 — map the app locale onto a BCP-47 tag with a region the
// recogniser accepts. The bare language subtag works in most engines, but
// the regioned form ("de-DE", "es-ES", …) yields materially better accuracy
// on Chromium / Safari. Unknown locales fall back to the bare subtag.
const SPEECH_RECOGNITION_LANG: Record<string, string> = {
en: "en-US",
de: "de-DE",
es: "es-ES",
fr: "fr-FR",
it: "it-IT",
pl: "pl-PL",
};
export function CoachInput({
value,
onChange,
@@ -274,6 +296,12 @@ export function CoachInput({
// always read the freshest composer state without re-subscribing.
const voiceSupported = useVoiceSupported();
const [listening, setListening] = useState(false);
// v1.25.0 — once the user denies the mic permission (or the engine reports
// it cannot serve), retire the control for the session rather than leaving a
// button that errors on every tap. Combined with `voiceSupported` this is
// the single gate the mic renders behind.
const [voiceBlocked, setVoiceBlocked] = useState(false);
const voiceAvailable = voiceSupported && !voiceBlocked;
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const onChangeRef = useRef(onChange);
const valueRef = useRef(value);
@@ -299,7 +327,7 @@ export function CoachInput({
const Ctor = getSpeechRecognitionCtor();
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = locale === "en" ? "en-US" : locale;
recognition.lang = SPEECH_RECOGNITION_LANG[locale] ?? locale;
recognition.continuous = true;
recognition.interimResults = true;
// Anchor every transcript onto the text present when dictation
@@ -321,15 +349,17 @@ export function CoachInput({
};
recognition.onerror = (event) => {
setListening(false);
// v1.18.10 (W4) — surface a quiet toast on a real failure so a tap
// that "does nothing" (denied mic permission, an insecure-context
// self-host, or no network for the recogniser) gives feedback rather
// than silently resetting. `aborted` / `no-speech` are the benign
// codes that fire on a normal stop or a quiet pause — stay silent.
// `aborted` / `no-speech` are the benign codes that fire on a normal
// stop or a quiet pause — stay silent.
const code = event?.error;
if (code && code !== "aborted" && code !== "no-speech") {
toast.error(t("insights.coach.dictateError"));
if (!code || code === "aborted" || code === "no-speech") return;
// v1.25.0 — a permission refusal (or an engine that reports it cannot
// serve) is terminal: surface one quiet toast and HIDE the mic for the
// session so the user is not left tapping a control that always errors.
if (code === "not-allowed" || code === "service-not-allowed") {
setVoiceBlocked(true);
}
toast.error(t("insights.coach.dictateError"));
};
recognition.onend = () => {
setListening(false);
@@ -346,18 +376,16 @@ export function CoachInput({
}, [locale, t]);
const toggleDictation = useCallback(() => {
if (!voiceSupported) return;
if (!voiceAvailable) return;
if (listening) stopDictation();
else startDictation();
}, [voiceSupported, listening, startDictation, stopDictation]);
}, [voiceAvailable, listening, startDictation, stopDictation]);
// The mic label tracks three states: unsupported (disabled + tooltip
// explaining why), listening (tap to stop), and idle (tap to dictate).
const micLabel = !voiceSupported
? t("insights.coach.voiceUnsupported")
: listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate");
// The mic only renders when dictation is available, so the label tracks the
// two live states: listening (tap to stop) and idle (tap to dictate).
const micLabel = listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate");
const canSubmit = !disabled && value.trim().length > 0;
@@ -381,19 +409,20 @@ export function CoachInput({
// v1.18.11 — the dictation mic. Lifted to a const so it can sit in the
// single-row composer (drawer) OR in the hub action row (page) without
// duplicating the wiring.
const micButton = (
// duplicating the wiring. v1.25.0 — `null` when dictation is unavailable
// (no API, non-secure context, or a denied permission) so the surface hides
// the control rather than advertising a button that errors on tap.
const micButton = voiceAvailable ? (
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled || !voiceSupported}
disabled={disabled}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
data-unsupported={!voiceSupported ? "true" : undefined}
aria-label={micLabel}
aria-pressed={voiceSupported ? listening : undefined}
aria-pressed={listening}
title={micLabel}
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
@@ -410,7 +439,7 @@ export function CoachInput({
aria-hidden="true"
/>
</Button>
);
) : null;
// v1.18.11 — the send / stop control. Same const-lift rationale as the
// mic: one definition, two mount points.
@@ -91,28 +91,31 @@ export function ScopeHintBadge({
</span>
{/* 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. */}
<div className="flex w-full items-center gap-1.5">
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. */}
<div className="flex w-full items-center justify-center gap-1.5">
<button
type="button"
data-slot="coach-scope-hint-seed"
onClick={() => onSeed(question)}
className={cn(
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
"flex min-w-0 flex-1 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"inline-flex max-w-full min-w-0 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="min-w-0 flex-1 text-sm leading-snug">
<span className="min-w-0 truncate text-sm leading-snug">
{question}
</span>
<span
aria-hidden="true"
className="text-muted-foreground group-hover:text-foreground text-xs whitespace-nowrap"
className="text-muted-foreground group-hover:text-foreground shrink-0 text-xs whitespace-nowrap"
>
{t("insights.coach.scope.tap")}
</span>
@@ -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 (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={points}
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
>
<XAxis dataKey="date" hide />
<YAxis domain={["auto", "auto"]} hide />
<Area
dataKey="base"
stackId="band"
stroke="none"
fill="none"
isAnimationActive={false}
/>
<Area
dataKey="range"
stackId="band"
stroke="none"
fill={color}
fillOpacity={0.15}
isAnimationActive={false}
/>
<Line
dataKey="projected"
type="monotone"
stroke={color}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
isAnimationActive={false}
/>
<ReferenceLine x="now" stroke="var(--border)" strokeDasharray="2 2" />
</ComposedChart>
</ResponsiveContainer>
);
}
@@ -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: () => <div className="bg-muted/20 h-full w-full rounded" />,
},
);
// 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 ? (
<span className="text-warning block font-medium">
{t(meta.caveatKey)}
</span>
) : null}
{t(meta.methodKey)}
</>
);
return (
<ProvenanceExplainer
provenance={provenance}
method={method}
standard={meta.standard}
/>
);
}
/** 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 (
<div
data-slot="trajectory-forecast-card"
data-density={compact ? "compact" : "default"}
className={cn(
"bg-card border-border flex w-full min-w-0 flex-col gap-2 rounded-xl border",
// Overview keeps the roomy `min-h-48` + `p-4 md:p-6` geometry for grid
// symmetry; sub-pages drop the height floor and tighten the padding so
// the no-trend state stops opening a tall empty band.
compact ? "p-4" : "min-h-48 p-4 md:p-6",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
{t("insights.derived.trajectory.cardTitle")}
</span>
{provenance ? <TrajectoryProvenance provenance={provenance} /> : null}
</div>
{children}
</div>
);
}
/** CLS-safe skeleton matching the resolved card geometry. */
function CardSkeleton({ compact = false }: { compact?: boolean }) {
return (
<div
data-slot="trajectory-forecast-card-skeleton"
aria-hidden="true"
className={cn(
"bg-card border-border flex w-full min-w-0 flex-col gap-2 rounded-xl border",
compact ? "p-4" : "min-h-48 p-4 md:p-6",
)}
>
<div className="flex h-11 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-32 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
</div>
<div
className={cn("bg-muted/40 w-full rounded", compact ? "h-12" : "h-24")}
/>
</div>
);
}
export function TrajectoryForecastCard({
type,
unit,
valueScale = 1,
color = "var(--chart-1)",
enabled = true,
compact = false,
className,
}: TrajectoryForecastCardProps) {
const { t } = useTranslations();
const { data } = useDerivedMetric<TrajectoryValue>(TRAJECTORY_METRIC, {
type,
enabled,
});
// Assemble the band chart series: the projected fan only (the engine
// returns the projection points; the recent observed value anchors it as
// the dashed line's left edge). Each projection point carries the band
// `low`/`base` (= bandLow) + `range` (= bandHigh − bandLow) so a stacked
// Area renders the fan, plus the projected line.
const chart = useMemo(() => {
if (!data || data.status !== "ok" || !data.value) return null;
const v = data.value;
const points = v.projection.map((p) => ({
date: p.date,
projected: scaled(p.projected, valueScale),
base: scaled(p.bandLow, valueScale),
range: scaled(p.bandHigh - p.bandLow, valueScale),
}));
// Anchor the fan at the last observed value so the dashed line departs
// from "today" rather than floating.
const anchor = {
date: "now",
projected: scaled(v.lastValue, valueScale),
base: scaled(v.lastValue, valueScale),
range: 0,
};
return {
points: [anchor, ...points],
lastValue: scaled(v.lastValue, valueScale),
};
}, [data, valueScale]);
if (!data) {
return (
<div className={className}>
<CardSkeleton compact={compact} />
</div>
);
}
// Below the fit / history / staleness floor — calm "no trend to project"
// state + coverage, never a weak line.
if (data.status === "insufficient" || !chart) {
// v1.12.0 — surface how much history is still missing before a projection
// can appear, drawn from the same coverage envelope the meter reads. The
// gating floor is distinct-in-window days, so the hint only shows when the
// shortfall is a history-depth one (the dominant insufficient cause); when
// the shortfall is fit quality (enough days, weak R²) the days are already
// there and the generic copy stands on its own.
const daysNeeded = Math.max(
0,
TRAJECTORY_MIN_HISTORY_DAYS - Math.floor(data.coverage.historyDays),
);
return (
<div className={className}>
<CardShell provenance={data.provenance} compact={compact}>
<LearningGate
compact={compact}
bodySlot="trajectory-insufficient"
message={t("insights.derived.trajectory.insufficient")}
caveat={
daysNeeded > 0
? t("insights.derived.trajectory.daysNeeded", {
count: daysNeeded,
})
: undefined
}
/>
<CoverageMeter coverage={data.coverage} />
</CardShell>
</div>
);
}
const v = data.value!;
const DirectionIcon =
v.direction === "up"
? TrendingUp
: v.direction === "down"
? TrendingDown
: Minus;
const last = chart.points[chart.points.length - 1];
const projectedLow = last.base;
const projectedHigh = last.base + last.range;
// The headline is conditional by construction — "if this pattern
// continues" — and quotes the RANGE, never a single certain number.
const rangeText = t("insights.derived.trajectory.range", {
low: Math.round(projectedLow * 10) / 10,
high: Math.round(projectedHigh * 10) / 10,
unit: unit ?? "",
days: v.horizonDays,
});
return (
<div className={className}>
<CardShell provenance={data.provenance} compact={compact}>
<div className="flex items-start gap-2">
<DirectionIcon
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="trajectory-headline"
>
{t("insights.derived.trajectory.headline", {
days: v.horizonDays,
})}
</p>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="trajectory-range"
>
{rangeText}
</p>
</div>
</div>
{/* The fan: a stacked transparent base + a shaded range Area, then
the dashed projected line. The shaded band visibly widens toward
the horizon — the uncertainty signal. */}
<div
className={cn("w-full", compact ? "h-20" : "h-28")}
data-slot="trajectory-chart"
>
<TrajectoryFanDynamic points={chart.points} color={color} />
</div>
{/* The mandatory conditional caveat — never a certainty, never a
dated clinical event. */}
<p
className={cn("text-muted-foreground text-xs leading-snug")}
data-slot="trajectory-caveat"
>
{t("insights.derived.trajectory.caveat")}
</p>
</CardShell>
</div>
);
}
@@ -19,8 +19,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card";
import { isTrajectoryType } from "@/lib/insights/derived/registry";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
@@ -150,17 +148,6 @@ export interface HealthKitMetricPageProps {
* is a compile error, not a silent 422.
*/
statusMetric?: MetricStatusMetricId;
/**
* v1.11.0 (Epic B, Pillar 3) — when `true`, mounts the short-horizon
* `<TrajectoryForecastCard>` beneath the chart for this metric (the page's
* `measurementType`). Opt-in: a page sets it only for a slow daily
* physiological series where a conservative 7–14-day OLS projection reads
* honestly. The card is only rendered on the data-bearing branch and only
* when the type is a supported trajectory metric; the engine still gates
* on R² / history / staleness, so a flat or noisy series shows the calm
* "no trend to project yet" state rather than a line.
*/
forecast?: boolean;
/**
* v1.16.16 — decimal precision for the stat-strip values. Defaults to the
* strip's own default (1). Blood glucose passes 0 for mg/dL (integer
@@ -203,7 +190,6 @@ export function HealthKitMetricPage({
targetSummarySlug,
statusMetric,
statIcon,
forecast = false,
statFractionDigits,
statMedianLabel,
afterChart,
@@ -397,16 +383,6 @@ export function HealthKitMetricPage({
{/* v1.17.0 — page-specific extra block (blood glucose: the clinical
panel). Mounted on the data-bearing branch only. */}
{afterChart}
{forecast && isTrajectoryType(measurementType) ? (
<TrajectoryForecastCard
type={measurementType}
unit={yAxisUnit ?? unit}
valueScale={valueScale}
color={color}
enabled={!isEmpty}
compact
/>
) : null}
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
{statusMetric ? (
@@ -62,33 +62,6 @@ async function fetchNarrative(
return apiGet<NarrativeResponse>(`/api/insights/narrative?period=${period}`);
}
/**
* v1.22 — the round ⓘ provenance glyph that hid the "how this was computed"
* read behind a popover is removed; the maintainer wants the trailing info
* affordance gone across the insights surface. The method line — and, when
* present, the metrics it drew on — now reads inline as a muted caption at the
* foot of the card, always visible rather than disclosure-only.
*/
function ProvenanceDisclosure({
provenance,
}: {
provenance: NarrativeProvenance;
}) {
const { t } = useTranslations();
const metrics = provenance.metrics.slice(0, 8).join(", ");
return (
<p
data-slot="period-narrative-provenance"
className="text-muted-foreground text-xs leading-snug"
>
{t("insights.narrativeProvenanceMethod")}
{metrics
? ` ${t("insights.narrativeProvenanceMetrics", { metrics })}`
: ""}
</p>
);
}
const SHELL =
"bg-card border-border flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6";
// The in-flight skeleton keeps a `min-h` floor so the initial paint reserves the
@@ -143,17 +116,16 @@ export function PeriodNarrativeCard({
);
}
// The route never errors the page; on a fetch error or a genuinely empty
// account (no narrative ever produced) the card un-mounts itself rather than
// showing an error or an empty husk on the overview.
if (query.isError || (!query.data?.narrative && !query.data?.revalidating)) {
// The route never errors the page; on a fetch error or whenever there is no
// real generated narrative yet (empty account, or a first warm still in
// flight) the card un-mounts itself rather than showing an error, a
// "preparing" husk, or the methodology framing. The query stays mounted and
// polls in the background, so the card appears the moment a narrative lands.
const narrative = query.data?.narrative ?? null;
if (query.isError || !narrative) {
return null;
}
const data = query.data;
const narrative = data?.narrative ?? null;
const preparing = !narrative && data?.revalidating === true;
return (
<section
data-slot="period-narrative-section"
@@ -194,50 +166,36 @@ export function PeriodNarrativeCard({
})}
</div>
{preparing ? (
<p className="text-muted-foreground text-sm">
{t("insights.narrativePreparing")}
</p>
) : (
// v1.22 (W6) — real paragraphs via the shared ProseBlocks helper
// (still plain text children — NO markdown renderer, XSS posture).
<div className="text-foreground text-sm">
<ProseBlocks text={narrative?.text ?? ""} />
</div>
)}
{/* v1.22 (W6) — real paragraphs via the shared ProseBlocks helper
(still plain text children — NO markdown renderer, XSS posture). */}
<div className="text-foreground text-sm">
<ProseBlocks text={narrative.text} />
</div>
{narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : null}
{narrative ? (
<p className="text-muted-foreground text-right text-xs">
{data?.revalidating
? t("insights.narrativeUpdating")
: formatUpdatedLabel(
narrative.updatedAt,
t,
fmt.dateShort,
fmt.time,
user?.timezone,
)}
</p>
) : null}
<p className="text-muted-foreground text-right text-xs">
{query.data?.revalidating
? t("insights.narrativeUpdating")
: formatUpdatedLabel(
narrative.updatedAt,
t,
fmt.dateShort,
fmt.time,
user?.timezone,
)}
</p>
{/* v1.21.0 (C4 H2) — hand the period summary to the Coach. It spans
the whole picture for the window, so no scope: the default
snapshot reads best. */}
{narrative ? (
<div className="flex justify-end">
<AskCoachAction
question={t(
period === "week"
? "insights.coach.seed.periodWeek"
: "insights.coach.seed.periodMonth",
)}
/>
</div>
) : null}
<div className="flex justify-end">
<AskCoachAction
question={t(
period === "week"
? "insights.coach.seed.periodWeek"
: "insights.coach.seed.periodMonth",
)}
/>
</div>
</div>
</section>
);
@@ -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>
@@ -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({
</div>
</div>
)}
{/* 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. */}
<div className="border-border/60 mt-4 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<p className="text-sm">
{t("notifications.coachNudge.suggestionsLabel")}
</p>
<p className="text-muted-foreground text-xs">
{t("notifications.coachNudge.suggestionsDesc")}
</p>
</div>
<Switch
checked={resolved.ambientSuggestions}
onCheckedChange={(next) =>
patchCoach({ ambientSuggestions: next }, null)
}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.coachNudge.suggestionsAria")}
/>
</div>
{msg && (
<p
role="status"
@@ -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> = {
@@ -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<AmbientSuggestionsPrefsShape>("/api/auth/me/notification-prefs"),
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
return data?.coach?.ambientSuggestions ?? true;
}
+38 -11
View File
@@ -34,10 +34,17 @@
* window. The DTO is the shared wire shape the route, the dashboard summary,
* and the iOS serializer all emit identically.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
parseSourcePriority,
resolveSleepDebtSource,
} from "@/lib/validations/source-priority";
import { loadBaselineProfile } from "./baseline";
import { reconstructNights, sleepNeedMinutes } from "./sleep-score";
import {
@@ -54,14 +61,14 @@ import {
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Trailing read window in days. The sleep-debt rolling window is 14 nights and
* chronotype wants a few weeks of free-day samples; 42 days (six weeks) gives
* the debt window full coverage and ~12 weekend nights for a stable MSF while
* staying a bounded per-stage read.
* Trailing read window in days. The sleep-debt rolling window is short (a few
* recent nights) and chronotype wants a few weeks of free-day samples; 42 days
* (six weeks) gives both windows full coverage and ~12 weekend nights for a
* stable MSF while staying a bounded per-stage read.
*/
const DEFAULT_WINDOW_DAYS = 42;
/** Wire DTO for the cumulative sleep debt — see `SleepDebtResult`. */
/** Wire DTO for the rolling sleep debt — see `SleepDebtResult`. */
export interface SleepDebtDto {
state: "partial" | "ready";
debtMinutes: number;
@@ -69,6 +76,13 @@ export interface SleepDebtDto {
nightsCounted: number;
windowNights: number;
nightsUntilReady: number;
/**
* v1.25.0 — the active source the debt figure is resolved FROM, picked off
* the user's `sleepDebt` source ladder. `COMPUTED` means HealthLog's own
* rolling-balance estimate (the only producer today); a provider value would
* mean a device-native debt. The UI explains the figure when it is COMPUTED.
*/
source: MeasurementSource;
}
/**
@@ -169,7 +183,10 @@ export function defaultDayType(night: string): "work" | "free" {
return weekday === 0 || weekday === 6 ? "free" : "work";
}
function toDebtDto(r: SleepDebtResult): SleepDebtDto {
function toDebtDto(
r: SleepDebtResult,
source: MeasurementSource,
): SleepDebtDto {
return {
state: r.state,
debtMinutes: r.debtMinutes,
@@ -177,6 +194,7 @@ function toDebtDto(r: SleepDebtResult): SleepDebtDto {
nightsCounted: r.nightsCounted,
windowNights: r.windowNights,
nightsUntilReady: r.nightsUntilReady,
source,
};
}
@@ -237,6 +255,10 @@ const CHRONOTYPE_WINDOW_NIGHTS = 42;
export function computeSleepRhythmFromNights(
nights: readonly RhythmNight[],
needMinutes: number,
// v1.25.0 — the resolved sleep-debt source off the user's ladder. Defaults to
// COMPUTED (the only producer today) so the dashboard + Coach snapshot
// callers, which always surface our own estimate, need not thread it.
sleepDebtSource: MeasurementSource = "COMPUTED",
): SleepRhythmDto {
// Sort ascending by wake-day key so "trailing N" is well-defined regardless
// of the caller's input order, then keep only scorable nights.
@@ -244,8 +266,8 @@ export function computeSleepRhythmFromNights(
.filter((n) => n.asleepMinutes > 0)
.sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0));
// Debt self-caps to its own 14-night window internally; pass every scorable
// night and let the module slice.
// Debt self-caps to its own short rolling window internally; pass every
// scorable night and let the module slice.
const debtNights: SleepDebtNight[] = scorable.map((n) => ({
night: n.night,
asleepMinutes: n.asleepMinutes,
@@ -273,7 +295,7 @@ export function computeSleepRhythmFromNights(
const averagePerNight = computeAverageSleep(scorable);
return {
sleepDebt: toDebtDto(sleepDebt),
sleepDebt: toDebtDto(sleepDebt, sleepDebtSource),
chronotype: toChronotypeDto(chronotype),
averagePerNight,
};
@@ -324,5 +346,10 @@ export async function buildSleepRhythm(
const nights = reconstructNights(rows, tz, priorityJson);
const needMinutes = sleepNeedMinutes(profile.ageYears);
return computeSleepRhythmFromNights(nights, needMinutes);
// v1.25.0 — resolve the active sleep-debt source off the user's ladder so the
// DTO (and the Sleep page) can name it and explain the COMPUTED estimate.
const sleepDebtSource = resolveSleepDebtSource(
parseSourcePriority(priorityJson),
);
return computeSleepRhythmFromNights(nights, needMinutes, sleepDebtSource);
}
+3
View File
@@ -292,6 +292,9 @@ const sleepDebtResource = z.object({
.describe(
"Nights still needed before the debt is asserted (0 when ready).",
),
source: measurementSourceEnum.describe(
"v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would be a device-native debt. Clients explain the figure when it is `COMPUTED`.",
),
});
const chronotypeBandEnum = z.enum([
@@ -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,
+24
View File
@@ -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 {
+37
View File
@@ -138,6 +138,12 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [
// collapsing to the alphabetical rank-90 tiebreak. Single-producer today,
// so the ladder is a no-op until the second producer lands.
"stress",
// v1.25.0 — native-vs-computed SLEEP DEBT. HealthLog computes a COMPUTED
// rolling-balance debt off the canonical nights; a provider that ships a
// native sleep-debt balance (none today) is the deferred second source.
// Laddering it now keeps the picker deterministic — and the choice user-
// configurable, not WHOOP-hardcoded — the instant a native producer lands.
"sleepDebt",
] as const;
export type SourcePriorityMetricKey =
@@ -309,6 +315,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// COMPUTED HRV-derived proxy; the proxy is the fallback for users without a
// device-native stress signal. Single-producer today.
stress: ["OURA", "COMPUTED"],
// v1.25.0 — native-vs-computed sleep debt. A device-native sleep-debt
// balance (none ships one today) would outrank HealthLog's COMPUTED
// rolling-balance debt; the COMPUTED engine is the fallback every user lands
// on now. Mirrors the recovery ladder's native-above-computed ordering.
sleepDebt: ["WHOOP", "OURA", "POLAR", "COMPUTED"],
};
/**
@@ -462,6 +473,32 @@ function buildResolved(
return resolved;
}
/**
* v1.25.0 — sources we can currently surface a SLEEP-DEBT figure from. Only
* the COMPUTED engine produces one today; a provider that ships a native
* sleep-debt balance is added here (and ingested) and then wins per the
* user's `sleepDebt` ladder with no change at the call site.
*/
export const SLEEP_DEBT_SUPPLIERS: ReadonlySet<
z.infer<typeof measurementSourceEnum>
> = new Set(["COMPUTED"]);
/**
* v1.25.0 — resolve the active sleep-debt source for a user: walk the
* `sleepDebt` ladder and return the highest-ranked source we can actually
* produce a debt for. Falls back to COMPUTED (always a supplier) when the
* ladder lists no available native source — which is every user today, so the
* UI knows to explain that the figure is our own computed estimate.
*/
export function resolveSleepDebtSource(
resolved: ResolvedSourcePriority,
): z.infer<typeof measurementSourceEnum> {
for (const source of resolved.metricPriority.sleepDebt) {
if (SLEEP_DEBT_SUPPLIERS.has(source)) return source;
}
return "COMPUTED";
}
/**
* v1.4.25 W8c — runtime device-type checker for ingest paths. Returns
* a `DeviceType` for any input the iOS app or Withings webhook might