feat(insights): show the wellness assessment and add score explainers

The score-anatomy detail page now renders the per-score AI assessment that
already rides GET /api/insights/derived (shipped to iOS in v1.13.2) — the web
type dropped the field, so the card was silently discarded. The assessment
mounts below the composition card via the same InsightStatusCard the metric
sub-pages use, so it reads identically for readiness, sleep, recovery, strain
and stress.

Each score detail page also gains an educational intro caption beneath the
heading, mirroring the metric sub-pages: new explainer bodies for the five
scores across all six locales (sleepScore kept distinct from the sleep
metric's own body), wired through SubPageShell's explainerMetric prop.

The explainer-key guard resolves the score page's closed lookup record
statically so the single dynamic prop stays verifiable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-07 18:03:49 +02:00
1 parent b07d77cbfa
commit e9171c1e5f
10 files changed
+156 -27

No files matched your search

+6 -1
View File
@@ -2177,7 +2177,12 @@
"stairDescentSpeedTitle": "Treppab-Tempo",
"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."
"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.",
"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 bedeutet, dass dein System erholt und belastbar wirkt; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten und deinem Körper Zeit zum Aufholen zu geben. Sieh ihn als Orientierung für die Tagesplanung, nicht als Urteil.",
"sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen und wägt ab, wie lange und wie effizient du geschlafen hast, wie regelmäßig deine Zeiten waren und wie sich Leicht-, Tief- und REM-Schlaf verteilen. Er soll auf einen Blick zeigen, ob die Nacht dich wirklich erholt hat. 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 und wie viel Kapazität du heute für Belastung hast — sie stützt sich 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. Er ist eine Orientierung, wie stark du dich einbringen kannst, keine starre Grenze.",
"strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen und spiegelt so, was speziell für dich fordernd ist. Nutze sie, um anstrengende Tage mit ruhigeren auszugleichen und dein Training tragfähig zu halten.",
"stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigeren, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen. Er ist ein beschreibender Näherungswert, um Trends zu erkennen, keine klinische Messung."
},
"stats": {
"label": "Zusammenfassende Statistik",
+6 -1
View File
@@ -2177,7 +2177,12 @@
"stairDescentSpeedTitle": "Stair descent speed",
"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."
"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.",
"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 your system looks recovered and resourced; a lower one is a gentle cue to ease off and let your body catch up. Treat it as guidance for pacing the day, not a verdict.",
"sleepScoreBody": "Your sleep score distils a whole night into one number, weighing how long you slept, how efficiently, how regular your timing was, and the balance of light, deep, and REM. It is meant to show at a glance whether the night truly restored you. 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 and how much capacity you have to take on load today, 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. It is a guide for how hard to lean in, not a limit.",
"strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, so it reflects what is demanding for you specifically. Use it to balance hard days with easier ones and keep your training sustainable.",
"stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calmer, well-regulated state; a higher one is an invitation to build in recovery. It is a descriptive proxy to notice trends, not a clinical measurement."
},
"stats": {
"label": "Summary statistics",
+6 -1
View File
@@ -2177,7 +2177,12 @@
"stairDescentSpeedTitle": "Velocidad bajando escaleras",
"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."
"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.",
"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 y dejar que tu cuerpo se ponga al día. Tómala como una guía para dosificar el día, no como un veredicto.",
"sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número, sopesando cuánto dormiste, con qué eficiencia, qué tan regular fue tu horario y el equilibrio entre sueño ligero, profundo y REM. Su objetivo es mostrar de un vistazo si la noche realmente te repuso. 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 y cuánta capacidad tienes hoy para asumir carga, 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. Es una guía sobre cuánto esforzarte, no un límite.",
"strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, de modo que refleja lo que resulta exigente para ti en concreto. Úsalo para equilibrar los días duros con otros más suaves y mantener un entrenamiento sostenible.",
"stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado más calmado y bien regulado; una más alta es una invitación a incorporar recuperación. Es una aproximación descriptiva para detectar tendencias, no una medición clínica."
},
"stats": {
"label": "Estadísticas resumidas",
+6 -1
View File
@@ -2177,7 +2177,12 @@
"stairDescentSpeedTitle": "Vitesse en descente d'escaliers",
"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."
"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.",
"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 et à laisser votre corps rattraper. Voyez-le comme un repère pour doser la journée, pas comme un verdict.",
"sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre, en pesant la durée de votre sommeil, son efficacité, la régularité de vos horaires et l'équilibre entre sommeil léger, profond et paradoxal. Il vise à montrer d'un coup d'œil si la nuit vous a vraiment restauré. 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 et la capacité dont vous disposez aujourd'hui pour encaisser de la charge, à 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. C'est un repère sur l'intensité à adopter, pas une limite.",
"strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel et reflète ainsi ce qui est exigeant pour vous en particulier. Servez-vous-en pour équilibrer les journées intenses et les plus douces, et garder un entraînement tenable.",
"stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état plus calme et bien régulé ; un score plus élevé invite à prévoir de la récupération. C'est une approximation descriptive pour repérer des tendances, pas une mesure clinique."
},
"stats": {
"label": "Statistiques récapitulatives",
+6 -1
View File
@@ -2177,7 +2177,12 @@
"stairDescentSpeedTitle": "Velocità in discesa scale",
"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."
"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.",
"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 e lasciare che il corpo recuperi. Consideralo una guida per dosare la giornata, non un verdetto.",
"sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero, valutando quanto hai dormito, con quale efficienza, quanto regolari sono stati i tuoi orari e l'equilibrio tra sonno leggero, profondo e REM. Serve a mostrare a colpo d'occhio se la notte ti ha davvero ristorato. 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 e quanta capacità hai oggi per sostenere il carico, 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. È una guida su quanto impegnarti, non un limite.",
"strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale e riflette così ciò che è impegnativo per te in particolare. Usalo per bilanciare le giornate intense con altre più leggere e mantenere un allenamento sostenibile.",
"stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato più calmo e ben regolato; uno più alto è un invito a inserire del recupero. È un'approssimazione descrittiva per notare le tendenze, non una misurazione clinica."
},
"stats": {
"label": "Statistiche riepilogative",
+6 -1
View File
@@ -2177,7 +2177,12 @@
"stairDescentSpeedTitle": "Prędkość schodzenia po schodach",
"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."
"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.",
"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 i dać ciału nadrobić zaległości. Traktuj go jako wskazówkę do rozłożenia sił w ciągu dnia, a nie jako wyrok.",
"sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie, biorąc pod uwagę, jak długo i jak efektywnie spałeś, jak regularne były Twoje pory oraz proporcje snu lekkiego, głębokiego i REM. Ma pokazać na pierwszy rzut oka, czy noc naprawdę Cię zregenerowała. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
"recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało i ile masz dziś zapasu, by przyjąć obciążenie — 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. To wskazówka, jak mocno się angażować, a nie sztywna granica.",
"strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, więc odzwierciedla to, co jest wymagające właśnie dla Ciebie. Wykorzystaj je, by równoważyć ciężkie dni lżejszymi i utrzymać trening na zrównoważonym poziomie.",
"stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojniejszy, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację. To opisowe przybliżenie pozwalające dostrzegać trendy, a nie pomiar kliniczny."
},
"stats": {
"label": "Statystyki podsumowujące",
@@ -26,6 +26,15 @@ import { join } from "node:path";
* both the `<value>Title` and `<value>Body` leaves exist in
* `messages/en.json`. `i18n-locale-integrity.test.ts` then propagates
* the EN guarantee across the other five locales.
*
* v1.15.12 B1 — the score-anatomy detail page (`scores/[metric]/page.tsx`)
* picks its explainer key at runtime from a closed `Record<…, string>`
* literal (`explainerMetric={EXPLAINER_METRIC[metric]}`), one of five score
* suffixes. That single dynamic site is resolved STATICALLY here by parsing
* the record's string-literal values (the contract stays verifiable). Score
* explainers render only the inline `Body` caption — there is no `?` popover
* `Title` for them — so the resolved score values are checked for a `Body`
* leaf only, while literal sites keep the full Title + Body pairing.
*/
const ROOT = join(__dirname, "../../../..");
@@ -53,21 +62,47 @@ interface PropSite {
value: string;
file: string;
line: number;
/** Score explainers render only the inline Body caption (no `?` Title). */
bodyOnly?: boolean;
}
// Match `explainerMetric="someValue"` — a string-literal prop. The
// component contract only accepts a static string, so a dynamic
// `explainerMetric={expr}` would be a different (and unverifiable)
// shape; none exist in the tree and we assert that below.
// component contract accepts a static string, OR the single closed-record
// lookup on the score-anatomy page (resolved statically below).
const LITERAL_PATTERN = /explainerMetric="([a-zA-Z][a-zA-Z0-9]*)"/g;
const DYNAMIC_PATTERN = /explainerMetric=\{/;
// v1.15.12 B1 — the one allowed dynamic site: the score-anatomy detail page
// reads its explainer key from a closed `EXPLAINER_METRIC` record literal.
// We resolve it statically — parse the record's string-literal values — so
// the dynamic site stays verifiable rather than escaping the guard.
const SCORES_PAGE = "src/app/insights/scores/[metric]/page.tsx";
const RECORD_LOOKUP_PATTERN = /explainerMetric=\{([A-Z_][A-Za-z0-9_]*)\[/;
const RECORD_VALUE_PATTERN = /:\s*"([a-zA-Z][a-zA-Z0-9]*)"/g;
/** Resolve a closed `const NAME = { … "value" … }` record's string values. */
function resolveRecordValues(source: string, recordName: string): string[] {
const start = source.indexOf(`const ${recordName}`);
if (start === -1) return [];
const open = source.indexOf("{", start);
const close = source.indexOf("}", open);
if (open === -1 || close === -1) return [];
const body = source.slice(open + 1, close);
const values: string[] = [];
let m: RegExpExecArray | null;
RECORD_VALUE_PATTERN.lastIndex = 0;
while ((m = RECORD_VALUE_PATTERN.exec(body)) !== null) values.push(m[1]);
return values;
}
function collectSites(): { sites: PropSite[]; dynamic: PropSite[] } {
const files = [...walk(INSIGHTS_DIR), HEALTHKIT_PAGE];
const sites: PropSite[] = [];
const dynamic: PropSite[] = [];
for (const file of files) {
const lines = readFileSync(file, "utf8").split("\n");
const rel = file.slice(ROOT.length + 1);
const source = readFileSync(file, "utf8");
const lines = source.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// The scaffold declares + forwards the prop; ignore the type
@@ -77,10 +112,20 @@ function collectSites(): { sites: PropSite[]; dynamic: PropSite[] } {
let m: RegExpExecArray | null;
LITERAL_PATTERN.lastIndex = 0;
while ((m = LITERAL_PATTERN.exec(line)) !== null) {
sites.push({ value: m[1], file: file.slice(ROOT.length + 1), line: i + 1 });
sites.push({ value: m[1], file: rel, line: i + 1 });
}
if (DYNAMIC_PATTERN.test(line)) {
dynamic.push({ value: line.trim(), file: file.slice(ROOT.length + 1), line: i + 1 });
// The score-anatomy record lookup is statically resolvable; every
// resolved value becomes a Body-only site. Any OTHER dynamic shape
// is unverifiable and trips the no-dynamic guard.
const lookup = RECORD_LOOKUP_PATTERN.exec(line);
if (file.endsWith(SCORES_PAGE) && lookup) {
for (const value of resolveRecordValues(source, lookup[1])) {
sites.push({ value, file: rel, line: i + 1, bodyOnly: true });
}
} else {
dynamic.push({ value: line.trim(), file: rel, line: i + 1 });
}
}
}
}
@@ -121,7 +166,8 @@ describe("insights explainerMetric key coverage", () => {
for (const site of sites) {
const titleKey = `insights.subPage.explainer.${site.value}Title`;
const bodyKey = `insights.subPage.explainer.${site.value}Body`;
if (!hasLeaf(en, titleKey)) {
// Score explainers (bodyOnly) carry only the inline Body caption.
if (!site.bodyOnly && !hasLeaf(en, titleKey)) {
missing.push(` ❌ ${titleKey} (${site.file}:${site.line})`);
}
if (!hasLeaf(en, bodyKey)) {
+16
View File
@@ -33,6 +33,21 @@ const SLUG_TO_METRIC: Record<string, AnatomyMetricId> = {
strain: "STRAIN_SCORE",
};
/**
* v1.15.12 B1 — the explainer-body key suffix per score, feeding
* `insights.subPage.explainer.<suffix>Body` through `SubPageShell`'s
* `explainerMetric` prop (mirrors the metric sub-pages). `SLEEP_SCORE`
* uses a distinct `sleepScore` suffix so it never collides with the
* `sleep` metric's own explainer body.
*/
const EXPLAINER_METRIC: Record<AnatomyMetricId, string> = {
SLEEP_SCORE: "sleepScore",
READINESS: "readiness",
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "stress",
STRAIN_SCORE: "strain",
};
/** Localised page header per metric (title + one-line description). */
const HEADER_KEYS: Record<
AnatomyMetricId,
@@ -71,6 +86,7 @@ export default function CompositeScorePage() {
<SubPageShell
title={t(header.title)}
description={t(header.description)}
explainerMetric={EXPLAINER_METRIC[metric]}
backLink={
<Button
asChild
@@ -1,8 +1,11 @@
"use client";
import { Sparkles } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useDerivedMetric } from "./use-derived-metric";
import { Skeleton } from "@/components/ui/skeleton";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import type {
ReadinessValue,
SleepScoreValue,
@@ -193,21 +196,43 @@ export function CompositeScoreAnatomy({
}
}
// v1.15.12 B3 — the per-score AI assessment ("why is this score what it is")
// already rides the single `/api/insights/derived` route as `data.assessment`
// (shipped to iOS in v1.13.2); the web simply discarded it. Render it BELOW
// the anatomy card, reusing the same `<InsightStatusCard>` the metric
// sub-pages mount so it reads identically. The route always populates a
// non-empty assessment when the score `status === "ok"` (the deterministic
// template at minimum, warmer AI prose when a provider is configured), so a
// present `assessment` implies a real text — `hasProvider` is held true and
// the card always renders its prose rather than the no-provider fallback.
const assessment = data.assessment;
return (
<ScoreAnatomyView
title={title}
score={score}
hue={METRIC_HUE[metric]}
caption={caption}
contributors={contributors}
coverage={data.coverage}
confidence={data.confidence}
provenance={data.provenance}
method={method}
standard={standard}
insufficient={insufficient}
className={className}
/>
<div className="space-y-3">
<ScoreAnatomyView
title={title}
score={score}
hue={METRIC_HUE[metric]}
caption={caption}
contributors={contributors}
coverage={data.coverage}
confidence={data.confidence}
provenance={data.provenance}
method={method}
standard={standard}
insufficient={insufficient}
className={className}
/>
{assessment ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Sparkles className="h-5 w-5" />}
text={assessment.text}
hasProvider
updatedAt={assessment.updatedAt}
/>
) : null}
</div>
);
}
@@ -8,6 +8,7 @@ import type {
DerivedConfidence,
DerivedProvenance,
} from "@/lib/insights/derived/types";
import type { DerivedAssessment } from "@/lib/insights/derived/derived-assessment";
/**
* v1.10.0 — the single TanStack Query hook for the generic derived-metric
@@ -44,6 +45,17 @@ export interface DerivedMetricResponse<T> {
confidence: DerivedConfidence | null;
provenance: DerivedProvenance;
reason: string | null;
/**
* v1.13.2 — the per-score "why is this score what it is" assessment
* ({ text, source, updatedAt }). Populated only for the assessable scores
* (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE) when
* `status === "ok"`, null otherwise. The single-metric route serves the
* AI-warm prose (falling back to the deterministic template); the batch route
* serves the cheap deterministic text only. Optional on the wire shape so
* grid reads that drop it stay typed. v1.15.12 B3 surfaces it on the web
* score-anatomy detail page (it already shipped to iOS in v1.13.2).
*/
assessment?: DerivedAssessment | null;
}
export interface UseDerivedMetricOptions {