mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user