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