fix(insights,dashboard): rest-mode web banner, sleep per-chart assessments, steps explainer, recovery heading level

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:20:41 +02:00
commit cdc76620fb
18 files changed
+249 -21

No files matched your search

+1
View File
@@ -4981,6 +4981,7 @@ paths:
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- SLEEP_SCORE
- ANS_CHARGE
- DAY_STRAIN
- WORKOUT_STRAIN
+7
View File
@@ -2654,6 +2654,8 @@
"stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
"breathingDisturbancesTitle": "Atemaussetzer im Schlaf",
"breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.",
"stepsTitle": "Schritte",
"stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung — eine höhere Tageszahl spiegelt meist einen aktiveren Lebensstil wider, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag.",
"readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.",
"sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.",
"recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
@@ -2758,6 +2760,11 @@
"critical": "Kritisch"
},
"assessmentTitle": "Einschätzung",
"restMode": {
"title": "Ruhemodus",
"body": "Eine Krankheits- oder Beschwerdephase ist aktiv, daher spiegelt dies eine Erholungsphase wider. Dein Score wird dafür nicht gesenkt.",
"bodySince": "Seit {since} ist eine Krankheits- oder Beschwerdephase aktiv, daher spiegelt dies eine Erholungsphase wider. Dein Score wird dafür nicht gesenkt."
},
"assessmentShowMore": "Mehr anzeigen",
"assessmentShowLess": "Weniger anzeigen",
"bloodPressureSectionTitle": "Blutdruck",
+7
View File
@@ -2654,6 +2654,8 @@
"stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.",
"breathingDisturbancesTitle": "Sleep breathing disturbances",
"breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.",
"stepsTitle": "Steps",
"stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement — a higher daily count generally reflects a more active lifestyle, and a steady habit matters more than any single big day.",
"readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.",
"sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.",
"recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding.",
@@ -2758,6 +2760,11 @@
"critical": "Critical"
},
"assessmentTitle": "Assessment",
"restMode": {
"title": "Rest Mode",
"body": "An illness or condition episode is active, so this reflects a recovery window. Your score isn't lowered for it.",
"bodySince": "An illness or condition episode has been active since {since}, so this reflects a recovery window. Your score isn't lowered for it."
},
"assessmentShowMore": "Show more",
"assessmentShowLess": "Show less",
"bloodPressureSectionTitle": "Blood Pressure",
+7
View File
@@ -2654,6 +2654,8 @@
"stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.",
"breathingDisturbancesTitle": "Alteraciones respiratorias del sueño",
"breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional.",
"stepsTitle": "Pasos",
"stepsBody": "El recuento de pasos es el número total de pasos que das cada día. Es la medida más sencilla del movimiento cotidiano: un recuento diario más alto suele reflejar un estilo de vida más activo, y un hábito constante importa más que un único día destacado.",
"readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo.",
"sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.",
"recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.",
@@ -2758,6 +2760,11 @@
"critical": "Crítico"
},
"assessmentTitle": "Valoración",
"restMode": {
"title": "Modo descanso",
"body": "Hay un episodio de enfermedad o afección activo, por lo que esto refleja un periodo de recuperación. Tu puntuación no baja por ello.",
"bodySince": "Hay un episodio de enfermedad o afección activo desde el {since}, por lo que esto refleja un periodo de recuperación. Tu puntuación no baja por ello."
},
"assessmentShowMore": "Mostrar más",
"assessmentShowLess": "Mostrar menos",
"bloodPressureSectionTitle": "Presión arterial",
+7
View File
@@ -2654,6 +2654,8 @@
"stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.",
"breathingDisturbancesTitle": "Troubles respiratoires du sommeil",
"breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel.",
"stepsTitle": "Pas",
"stepsBody": "Le nombre de pas correspond au total de pas que vous faites chaque jour. C'est la mesure la plus simple de l'activité quotidienne : un total journalier plus élevé reflète généralement un mode de vie plus actif, et une habitude régulière compte plus qu'une seule grosse journée.",
"readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied.",
"sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.",
"recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.",
@@ -2758,6 +2760,11 @@
"critical": "Critique"
},
"assessmentTitle": "Évaluation",
"restMode": {
"title": "Mode repos",
"body": "Un épisode de maladie ou d'affection est en cours, ceci reflète donc une période de récupération. Votre score n'en est pas pénalisé.",
"bodySince": "Un épisode de maladie ou d'affection est en cours depuis le {since}, ceci reflète donc une période de récupération. Votre score n'en est pas pénalisé."
},
"assessmentShowMore": "Afficher plus",
"assessmentShowLess": "Afficher moins",
"bloodPressureSectionTitle": "Tension artérielle",
+7
View File
@@ -2654,6 +2654,8 @@
"stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.",
"breathingDisturbancesTitle": "Disturbi respiratori nel sonno",
"breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico.",
"stepsTitle": "Passi",
"stepsBody": "Il conteggio dei passi è il numero totale di passi che fai ogni giorno. È la misura più semplice del movimento quotidiano: un conteggio giornaliero più alto riflette di solito uno stile di vita più attivo, e un'abitudine costante conta più di un singolo giorno intenso.",
"readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare.",
"sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.",
"recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.",
@@ -2758,6 +2760,11 @@
"critical": "Critico"
},
"assessmentTitle": "Valutazione",
"restMode": {
"title": "Modalità riposo",
"body": "È in corso un episodio di malattia o disturbo, quindi questo riflette una fase di recupero. Il tuo punteggio non viene abbassato per questo.",
"bodySince": "È in corso un episodio di malattia o disturbo dal {since}, quindi questo riflette una fase di recupero. Il tuo punteggio non viene abbassato per questo."
},
"assessmentShowMore": "Mostra altro",
"assessmentShowLess": "Mostra meno",
"bloodPressureSectionTitle": "Pressione arteriosa",
+7
View File
@@ -2654,6 +2654,8 @@
"stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.",
"breathingDisturbancesTitle": "Zaburzenia oddychania we śnie",
"breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem.",
"stepsTitle": "Kroki",
"stepsBody": "Liczba kroków to łączna liczba kroków wykonanych każdego dnia. To najprostsza miara codziennego ruchu — wyższa dzienna liczba zwykle odzwierciedla bardziej aktywny tryb życia, a stały nawyk liczy się bardziej niż pojedynczy intensywny dzień.",
"readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo.",
"sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
"recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.",
@@ -2758,6 +2760,11 @@
"critical": "Krytyczny"
},
"assessmentTitle": "Ocena",
"restMode": {
"title": "Tryb odpoczynku",
"body": "Trwa epizod choroby lub dolegliwości, więc odzwierciedla to okres rekonwalescencji. Twój wynik nie jest za to obniżany.",
"bodySince": "Od {since} trwa epizod choroby lub dolegliwości, więc odzwierciedla to okres rekonwalescencji. Twój wynik nie jest za to obniżany."
},
"assessmentShowMore": "Pokaż więcej",
"assessmentShowLess": "Pokaż mniej",
"bloodPressureSectionTitle": "Ciśnienie krwi",
@@ -49,6 +49,7 @@ export const dynamic = "force-dynamic";
const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
SLEEP_DURATION: "sleep",
BREATHING_DISTURBANCES: "sleep",
SLEEP_SCORE: "sleep",
BLOOD_GLUCOSE: "glucose",
CARDIO_RECOVERY: "recovery",
// v1.18.1 — device-native recovery / strain signals gate on the
+22 -15
View File
@@ -95,6 +95,26 @@ export default function InsightsSchlafPage() {
>
<SleepOverview />
{/*
v1.8.7.1 — the duration/architecture assessment lands via the generic
metric-status route (`?metric=SLEEP_DURATION`). The shared
`<MetricStatusCard>` owns the hook + card; only the icon differs from
the HealthKit scaffold (Moon vs Sparkles).
v1.18.1 — moved up to sit DIRECTLY with the duration/architecture chart
group it describes (`<SleepOverview>`), rather than trailing the whole
page. The sleep-quality block below now carries its OWN assessment, so
each distinct chart group reads its own matching text — the canonical
"multiple charts ⇒ multiple texts" rule the recovery page meets. The
card mounts only on this data-bearing branch, so a source-less account
never fetches.
*/}
<MetricStatusCard
metric="SLEEP_DURATION"
icon={<Moon className="h-5 w-5" />}
enabled={!isEmpty}
/>
{/*
v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative
(the same DTO iOS renders), gated on the data-bearing branch so a
@@ -108,25 +128,12 @@ export default function InsightsSchlafPage() {
disturbance count + the Oura sleep score) that were ingested but never
rendered. Each tile is data-gated, so the block is invisible for
non-wearable users and collapses metric-by-metric. Server-authoritative
— the tiles render stored values.
— the tiles render stored values. v1.18.1 — the block carries its own
`<MetricStatusCard metric="SLEEP_SCORE">` assessment internally.
*/}
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
v1.8.7.1 — the per-section sleep assessment lands via the generic
metric-status route (`?metric=SLEEP_DURATION`), closing the slot
the v1.4.28 deferral left open. The shared `<MetricStatusCard>`
owns the hook + card; only the icon differs from the HealthKit
scaffold (Moon vs Sparkles). The card mounts only on this
data-bearing branch, so a source-less account never fetches.
*/}
<MetricStatusCard
metric="SLEEP_DURATION"
icon={<Moon className="h-5 w-5" />}
enabled={!isEmpty}
/>
</SubPageShell>
);
}
+1
View File
@@ -24,6 +24,7 @@ export default function InsightsStepsPage() {
insightMetric="ACTIVITY_STEPS"
chartKey="steps"
i18nPrefix="insights.steps"
explainerMetric="steps"
statIcon={Footprints}
color="#50fa7b"
unit="steps"
@@ -47,6 +47,7 @@ import Link from "next/link";
import { Pill } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import {
resolveDashboardVerdict,
type DashboardVerdictVariant,
@@ -230,7 +231,8 @@ export function DashboardHero({
"min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6",
)}
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting leads the typographic hierarchy: larger + heavier
than the verdict so the personalised line reads first.
@@ -316,6 +318,12 @@ export function DashboardHero({
/>
</div>
</div>
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating
(renders nothing unless an episode is active), value-free, and
untinted so it frames the score without alarming. Closes the
web↔iOS parity gap: iOS already mirrors `score.restMode`. */}
<RestModeBanner annotation={snapshot.healthScore?.restMode ?? null} />
</div>
</section>
);
}
@@ -77,7 +77,12 @@ export function RecoveryMetricBlock({
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<h2 className="text-base font-semibold">{title}</h2>
{/* v1.18.1 — block title is an `<h3>` (kept at `text-base`): it
nests under the group `SectionHeading` `<h2>`, which itself
sits under the page `<h1>` from `SubPageShell`. Demoting it
keeps the recovery page's heading outline strictly nested
(h1 → h2 → h3) instead of stacking two sibling `<h2>`s. */}
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{explainer}
@@ -11,10 +11,13 @@ import {
} from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionHeading } from "@/components/insights/section-heading";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block";
@@ -63,6 +66,15 @@ interface BlockGroup {
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
const restMode =
((analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null)
?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
const unitScore = t("insights.deviceScore.unitScore");
@@ -200,6 +212,9 @@ export function RecoverySection() {
return (
<div className="space-y-8" data-slot="recovery-section">
{/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface.
Self-gating: renders nothing unless an episode is active. */}
<RestModeBanner annotation={restMode} />
{groups.map((group) => {
const present = group.blocks.filter(
(block) => (summaries[block.type]?.count ?? 0) > 0,
@@ -0,0 +1,70 @@
"use client";
import { HeartPulse } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
/**
* v1.18.1 — calm, neutral Rest Mode indicator.
*
* Rest Mode is the server-authoritative "an illness/condition episode is
* active" context (`@/lib/illness/rest-mode`). When it is active the score,
* recovery, and streak surfaces are FRAMED — never penalised — and iOS already
* mirrors the annotation verbatim. The web surfaces (dashboard score card,
* recovery insight) previously read the score with no on-screen explanation,
* so a self-hoster saw their number behave differently with no cue. This
* banner closes that web↔iOS parity gap.
*
* Contract (mirrors the audit's "value-free" requirement):
* - renders nothing when `annotation` is null or inactive (the common case);
* - never surfaces a decrypted note, an episode label, or any health value —
* the only data shown is the onset DATE (a calendar anchor, not a reading);
* - carries NO colour tint (a tinted "you're unwell" banner would alarm, not
* reassure) — it wears the same neutral bordered card the trend-annotation
* primitive uses so it reads as one of the calm insight surfaces.
*
* Pure presentational: the caller resolves `annotation` from the
* server-authoritative score payload and passes it in.
*/
export function RestModeBanner({
annotation,
}: {
annotation: RestModeAnnotation | null | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!annotation || !annotation.active) return null;
// The onset date is a calendar anchor, not a health value. Format it through
// the user's locale formatter when present; fall back to the value-free body
// when the score payload carried no onset (e.g. a condition with no recorded
// start).
const since = annotation.since;
const sinceLabel =
since && !Number.isNaN(Date.parse(since)) ? fmt.date(new Date(since)) : null;
return (
<div
data-slot="rest-mode-banner"
role="status"
className="border-border/60 bg-card/40 flex items-start gap-2 rounded-md border p-3"
>
<HeartPulse
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("insights.restMode.title")}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{sinceLabel
? t("insights.restMode.bodySince", { since: sinceLabel })
: t("insights.restMode.body")}
</p>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
/**
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
@@ -100,6 +101,13 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
);
if (present.length === 0) return null;
// v1.18.1 — the quality block is a distinct chart group, so it carries its
// OWN assessment (matching the canonical "multiple charts ⇒ multiple texts"
// rule the recovery page meets). Gated on the nightly headline sleep score
// having data so the generic assessment route only fires when there is a
// quality series to read.
const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
return (
<section data-slot="sleep-quality-section" className="space-y-3">
<SectionHeading
@@ -124,6 +132,11 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
/>
))}
</div>
<MetricStatusCard
metric="SLEEP_SCORE"
icon={<Moon className="h-5 w-5" />}
enabled={hasSleepScore}
/>
</section>
);
}
+17 -3
View File
@@ -294,7 +294,14 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
expect(snap.extras).not.toBeNull();
expect(snap.extras!.bpInTargetPct).toBe(80);
expect(snap.healthScore).toEqual({ score: 71, band: "yellow", delta: 3 });
// v1.18.1 — `restMode` is null when no illness episode is active (the
// fail-soft resolver reads no active episodes off the fake prisma).
expect(snap.healthScore).toEqual({
score: 71,
band: "yellow",
delta: 3,
restMode: null,
});
// v1.17 W1b — two runs (current + prior-week), identical to the analytics
// route, so the ring's delta reflects BP movement.
expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(2);
@@ -426,8 +433,15 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
// Score + band + delta only — components never reach the wire.
expect(snap.healthScore).toEqual({ score: 72, band: "yellow", delta: -12 });
// Score + band + delta (+ the v1.18.1 value-free restMode annotation)
// only — components never reach the wire. `restMode` is null with no
// active illness episode.
expect(snap.healthScore).toEqual({
score: 72,
band: "yellow",
delta: -12,
restMode: null,
});
// The fast path reuses the BP windows + coverage map already
// computed for `extras` (no re-probe, graded score threaded).
+33 -1
View File
@@ -77,7 +77,11 @@ import {
type MedsTodayBlock,
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import type { HealthScoreBand } from "@/lib/analytics/health-score";
import type {
HealthScoreBand,
RestModeAnnotation,
} from "@/lib/analytics/health-score";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
@@ -270,6 +274,17 @@ export interface DashboardSnapshotHealthScore {
score: number;
band: HealthScoreBand;
delta: number | null;
/**
* v1.18.1 — Rest Mode annotation. When an illness/condition episode is
* active the dashboard frames the score ("you were unwell during this
* window") WITHOUT changing it — the same value-free context the
* `/api/analytics` payload carries and iOS mirrors. Null when the account
* is not in Rest Mode. Resolved server-side (fail-soft) so the surface
* never recomputes it. Optional on the type (additive contract) so older
* cached snapshots + test fixtures without the field stay valid; the live
* builder always sets it (null when not in Rest Mode).
*/
restMode?: RestModeAnnotation | null;
}
export interface DashboardSnapshot {
@@ -509,11 +524,28 @@ async function buildExtras(
coverage,
}),
);
// v1.18.1 — resolve the value-free Rest Mode annotation alongside the score
// so the dashboard hero can frame (never penalise) the number, matching the
// `/api/analytics` payload + iOS. Fail-soft inside `resolveRestMode`, and
// only resolved when a score actually rendered.
const restMode: RestModeAnnotation | null = scoreResult
? await (async () => {
const ctx = await resolveRestMode(user.id, now);
return ctx.active
? {
active: true,
since: ctx.since,
episodeCount: ctx.episodeCount,
}
: null;
})()
: null;
const healthScore: DashboardSnapshotHealthScore | null = scoreResult
? {
score: scoreResult.score,
band: scoreResult.band,
delta: scoreResult.delta,
restMode,
}
: null;
@@ -118,6 +118,11 @@ export type MetricStatusMetricId =
| "STAIR_ASCENT_SPEED"
| "STAIR_DESCENT_SPEED"
| "BREATHING_DISTURBANCES"
// v1.18.1 — nightly sleep-quality headline (WHOOP / Oura sleep score).
// Carries a generic assessment so the Sleep page reads one matching text
// per distinct chart group (duration/architecture AND quality), meeting
// the canonical "multiple charts ⇒ multiple texts" rule recovery meets.
| "SLEEP_SCORE"
// v1.18.1 — device-native recovery / strain signals (WHOOP / Polar /
// Oura). Each carries a generic assessment so the rebuilt
// `/insights/recovery` page reads one matching text per chart.
@@ -505,6 +510,20 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
direction: "lower-better",
archetype: "sleep",
},
// Sleep score — the nightly headline sleep-quality score (WHOOP sleep
// performance lineage / Oura sleep score), 0–100. Higher is the better
// night. No fixed clinical band: the scales differ by device and the
// user's own baseline carries the read; the assessment frames the trend.
// Sits on the dedicated sleep template so its prose matches the duration
// assessment's tone.
SLEEP_SCORE: {
id: "SLEEP_SCORE",
measurementType: "SLEEP_SCORE",
displayName: "Sleep score",
unit: "score",
direction: "higher-better",
archetype: "sleep",
},
// ── v1.18.1 device-native recovery / strain signals ──
// ANS charge — autonomic-nervous-system recharge (Polar Nightly
// Recharge / ring readiness lineage). Higher = better recovered. No