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

@@ -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 {