From fe6d4c10dfd683995170f5c770df81289f811265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:22:21 +0200 Subject: [PATCH 1/3] Add typed Learn-article link registry Promote the static /learn guide catalog into a typed concept-to-slug lookup the deterministic UI surfaces can consume. learnUrl is the only sanctioned /learn URL builder and learnLinkForMetric fails closed for an unmapped id. A test asserts every mapped slug resolves in the catalog, so the mapping can never point at a guide that does not exist. --- src/lib/__tests__/learn-links.test.ts | 41 ++++++++ src/lib/learn-links.ts | 134 ++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/lib/__tests__/learn-links.test.ts create mode 100644 src/lib/learn-links.ts diff --git a/src/lib/__tests__/learn-links.test.ts b/src/lib/__tests__/learn-links.test.ts new file mode 100644 index 000000000..140a051b8 --- /dev/null +++ b/src/lib/__tests__/learn-links.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; + +import { LEARN_GUIDES } from "@/lib/ai/coach/learn-catalog"; +import { LEARN_LINKS, learnUrl, learnLinkForMetric } from "../learn-links"; + +const CATALOG_SLUGS = new Set(LEARN_GUIDES.map((g) => g.slug)); + +describe("learn-links registry", () => { + it("every mapped concept points at a slug that exists in the catalog", () => { + for (const [concept, slug] of Object.entries(LEARN_LINKS)) { + expect(CATALOG_SLUGS.has(slug), `${concept} → ${slug}`).toBe(true); + } + }); + + it("learnUrl mints the catalog URL for a known slug and null otherwise", () => { + const sample = LEARN_GUIDES[0]!; + expect(learnUrl(sample.slug)).toBe(sample.url); + expect(learnUrl(sample.slug)).toMatch(/^https:\/\/healthlog\.dev\/learn\//); + expect(learnUrl("not-a-real-slug")).toBeNull(); + expect(learnUrl("")).toBeNull(); + }); + + it("learnLinkForMetric resolves a mapped metric to its guide", () => { + const guide = learnLinkForMetric("RESTING_HEART_RATE"); + expect(guide).not.toBeNull(); + expect(guide?.slug).toBe("resting-heart-rate"); + expect(guide?.url).toBe(learnUrl("resting-heart-rate")); + }); + + it("learnLinkForMetric fails closed for an unmapped id", () => { + expect(learnLinkForMetric("TOTALLY_UNKNOWN_METRIC")).toBeNull(); + expect(learnLinkForMetric("")).toBeNull(); + }); + + it("learnUrl is the only path — every guide URL stays on the public learn base", () => { + for (const g of LEARN_GUIDES) { + expect(learnUrl(g.slug)).toBe(g.url); + expect(g.url).toMatch(/^https:\/\/healthlog\.dev\/learn\/[a-z0-9-]+$/); + } + }); +}); diff --git a/src/lib/learn-links.ts b/src/lib/learn-links.ts new file mode 100644 index 000000000..009b2c374 --- /dev/null +++ b/src/lib/learn-links.ts @@ -0,0 +1,134 @@ +/** + * v1.21.0 — typed Learn-article link registry for deterministic UI surfaces. + * + * The public /learn guides are the secondary, plain-language education layer + * (the primary-source guideline/study citations live separately in + * `medical-citations.ts`). The Coach already references the guides through its + * static catalog (`src/lib/ai/coach/learn-catalog.ts`); this module promotes + * that same catalog into a typed `concept → guide` lookup the React surfaces + * can consume. + * + * Single source of truth: the slug list is NOT duplicated here — every URL is + * minted from a `LEARN_GUIDES` entry via `learnUrl(slug)`, which is the ONLY + * sanctioned way to build a /learn URL outside the catalog itself. A concept + * with no mapped guide returns `null` (fail-closed) so a surface never renders + * an invented href. + */ + +import { LEARN_GUIDES, type LearnGuide } from "@/lib/ai/coach/learn-catalog"; + +/** Fast slug → guide index over the published catalog. */ +const GUIDE_BY_SLUG: ReadonlyMap = new Map( + LEARN_GUIDES.map((g) => [g.slug, g]), +); + +/** + * The catalog slugs, narrowed to a literal union so a typo in `LEARN_LINKS` + * is a compile error rather than a dead link. Mirrors the 19 published guides. + */ +export type LearnSlug = + | "understanding-your-health-metrics" + | "resting-heart-rate" + | "heart-rate-variability" + | "reading-your-blood-pressure" + | "sleep-consistency" + | "respiratory-rate" + | "blood-oxygen-spo2" + | "body-temperature-baseline" + | "blood-sugar-beyond-diabetes" + | "vo2max-and-longevity" + | "beyond-the-scale" + | "tracking-mood" + | "the-cycle-as-a-vital-sign" + | "how-wearables-measure-you" + | "reading-your-trends" + | "steps-and-movement" + | "caffeine-alcohol-and-your-readings" + | "hydration-and-your-body" + | "stress-and-recovery"; + +/** + * Stable concept keys the UI maps to a guide. The metric-shaped keys reuse the + * app's own `InsightMetric` identifiers verbatim (so a vitals tile / metric + * page passes its metric id straight through); the remaining keys name a + * cross-cutting concept (a composite score, a trend explainer, the generic + * lab biomarker fallback). + */ +export type LearnConcept = + // metric-shaped (subset of InsightMetric ids that have a matching guide) + | "RESTING_HEART_RATE" + | "HEART_RATE_VARIABILITY" + | "HRV_RMSSD" + | "BLOOD_PRESSURE_SYS" + | "BLOOD_PRESSURE_DIA" + | "OXYGEN_SATURATION" + | "RESPIRATORY_RATE" + | "BODY_TEMPERATURE" + | "SKIN_TEMPERATURE" + | "BLOOD_GLUCOSE" + | "VO2_MAX" + | "WEIGHT" + | "BMI" + | "MOOD" + | "STEPS" + | "ACTIVITY_STEPS" + // concept-shaped (no single metric id) + | "HEALTH_METRICS_OVERVIEW" + | "TRENDS" + | "RESILIENCE" + | "SLEEP_CONSISTENCY" + | "CYCLE" + | "WEARABLES" + | "LAB_BIOMARKER"; + +/** + * The frozen `concept → slug` mapping. Slugs are typed against `LearnSlug`, and + * a unit test asserts each one resolves in `LEARN_GUIDES` — so the mapping can + * never point at a guide that does not exist. + */ +export const LEARN_LINKS: Readonly> = + Object.freeze({ + RESTING_HEART_RATE: "resting-heart-rate", + HEART_RATE_VARIABILITY: "heart-rate-variability", + HRV_RMSSD: "heart-rate-variability", + BLOOD_PRESSURE_SYS: "reading-your-blood-pressure", + BLOOD_PRESSURE_DIA: "reading-your-blood-pressure", + OXYGEN_SATURATION: "blood-oxygen-spo2", + RESPIRATORY_RATE: "respiratory-rate", + BODY_TEMPERATURE: "body-temperature-baseline", + SKIN_TEMPERATURE: "body-temperature-baseline", + BLOOD_GLUCOSE: "blood-sugar-beyond-diabetes", + VO2_MAX: "vo2max-and-longevity", + WEIGHT: "beyond-the-scale", + BMI: "beyond-the-scale", + MOOD: "tracking-mood", + STEPS: "steps-and-movement", + ACTIVITY_STEPS: "steps-and-movement", + HEALTH_METRICS_OVERVIEW: "understanding-your-health-metrics", + TRENDS: "reading-your-trends", + RESILIENCE: "stress-and-recovery", + SLEEP_CONSISTENCY: "sleep-consistency", + CYCLE: "the-cycle-as-a-vital-sign", + WEARABLES: "how-wearables-measure-you", + LAB_BIOMARKER: "understanding-your-health-metrics", + }); + +/** + * The ONLY sanctioned /learn URL builder. Resolves a catalog slug to its + * absolute public URL; returns `null` for an unknown slug (fail-closed). No + * other file should string-concatenate a /learn URL. + */ +export function learnUrl(slug: string): string | null { + return GUIDE_BY_SLUG.get(slug)?.url ?? null; +} + +/** + * Resolve a concept key to its guide, or `null` when the concept is unmapped + * or — defensively — its slug is missing from the catalog. A surface passes + * the result straight to ``, which renders nothing on `null`. + */ +export function learnLinkForMetric(metricId: string): LearnGuide | null { + const slug = (LEARN_LINKS as Record)[metricId]; + if (slug == null) return null; + return GUIDE_BY_SLUG.get(slug) ?? null; +} From 6ed5b55f3d0e8977ed49bf40341884777d12187d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:22:27 +0200 Subject: [PATCH 2/3] Add discreet LearnMoreLink pointer component A small, calm anchor that links a concept out to its public /learn guide. Fail-closed: the href is registry-backed (a closed-set value, never user input) and an unmapped concept renders nothing. Plain anchor, no markdown. Adds a common.learnMore label across all locales. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/components/ui/learn-more-link.tsx | 57 +++++++++++++++++++++++++++ 7 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 src/components/ui/learn-more-link.tsx diff --git a/messages/de.json b/messages/de.json index 277b88ac3..62fa4aec1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -34,7 +34,8 @@ "retry": "Erneut versuchen", "reportIssue": "Fehler melden", "showPassword": "Passwort anzeigen", - "hidePassword": "Passwort verbergen" + "hidePassword": "Passwort verbergen", + "learnMore": "Mehr erfahren" }, "doctorReport": { "title": "Gesundheitsbericht", diff --git a/messages/en.json b/messages/en.json index bf05239fc..30a6934e1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -34,7 +34,8 @@ "retry": "Retry", "reportIssue": "Report issue", "showPassword": "Show password", - "hidePassword": "Hide password" + "hidePassword": "Hide password", + "learnMore": "Learn more" }, "doctorReport": { "title": "Health Report", diff --git a/messages/es.json b/messages/es.json index bb1b6992f..9ab22a6a3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -34,7 +34,8 @@ "retry": "Reintentar", "reportIssue": "Reportar problema", "showPassword": "Mostrar contraseña", - "hidePassword": "Ocultar contraseña" + "hidePassword": "Ocultar contraseña", + "learnMore": "Más información" }, "doctorReport": { "title": "Informe de salud", diff --git a/messages/fr.json b/messages/fr.json index fb09bcfa2..d2df8eb8d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -34,7 +34,8 @@ "retry": "Réessayer", "reportIssue": "Signaler un problème", "showPassword": "Afficher le mot de passe", - "hidePassword": "Masquer le mot de passe" + "hidePassword": "Masquer le mot de passe", + "learnMore": "En savoir plus" }, "doctorReport": { "title": "Rapport de santé", diff --git a/messages/it.json b/messages/it.json index 9c2a5496c..46e4e9c83 100644 --- a/messages/it.json +++ b/messages/it.json @@ -34,7 +34,8 @@ "retry": "Riprova", "reportIssue": "Segnala un problema", "showPassword": "Mostra password", - "hidePassword": "Nascondi password" + "hidePassword": "Nascondi password", + "learnMore": "Scopri di più" }, "doctorReport": { "title": "Rapporto sanitario", diff --git a/messages/pl.json b/messages/pl.json index e4c29338e..6df51477c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -34,7 +34,8 @@ "retry": "Spróbuj ponownie", "reportIssue": "Zgłoś problem", "showPassword": "Pokaż hasło", - "hidePassword": "Ukryj hasło" + "hidePassword": "Ukryj hasło", + "learnMore": "Dowiedz się więcej" }, "doctorReport": { "title": "Raport zdrowotny", diff --git a/src/components/ui/learn-more-link.tsx b/src/components/ui/learn-more-link.tsx new file mode 100644 index 000000000..df5c47b2c --- /dev/null +++ b/src/components/ui/learn-more-link.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ExternalLink } from "lucide-react"; + +import { learnLinkForMetric } from "@/lib/learn-links"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.21.0 — discreet "Learn more" pointer to a public /learn guide. + * + * One small, calm anchor (no new colour, no card chrome) that links a concept + * out to its plain-language guide. Fail-closed by construction: + * + * - the href is resolved through `learnLinkForMetric` — a closed-set, + * registry-backed URL, never user/model content — so there is no XSS + * surface and no invented link; + * - an unmapped concept resolves to `null` and the component renders + * nothing, so a surface that wires a concept with no guide simply shows + * no pointer rather than a broken one. + * + * It is a literal `` (no markdown library): `target="_blank"` with + * `rel="noopener noreferrer"`, the guide title as the accessible label. + */ +export interface LearnMoreLinkProps { + /** + * A `LearnConcept` key (or any metric id) to resolve. Anything unmapped + * renders nothing. + */ + concept: string; + className?: string; +} + +export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) { + const { t } = useTranslations(); + const guide = learnLinkForMetric(concept); + if (guide == null) return null; + + const label = t("common.learnMore"); + + return ( + + {label} + + ); +} From 8c58b5b1c77f500da9297a091710703742a7b145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:22:33 +0200 Subject: [PATCH 3/3] Wire Learn pointers into vitals, glucose, resilience, and lab surfaces Add a single discreet LearnMoreLink to the per-vital baseline tiles (via a new footer slot on the shared tile), the glucose clinical panel, the resilience tile, and the lab biomarker detail. Each pointer is registry-resolved and renders nothing when the concept has no mapped guide, so only surfaces with a real article show one. --- .../insights/derived/sparkline-delta-tile.tsx | 13 +++++++++++++ .../insights/derived/vitals-dashboard.tsx | 2 ++ .../insights/glucose/glucose-clinical-panel.tsx | 5 +++++ .../insights/recovery/resilience-tile.tsx | 2 ++ src/components/labs/lab-biomarker-detail.tsx | 3 +++ 5 files changed, 25 insertions(+) diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index 9ca90671c..5e249fe08 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -62,6 +62,12 @@ export interface SparklineDeltaTileProps { * explainer; the tile just gives it a slot. */ provenance?: React.ReactNode; + /** + * Optional quiet footer slot — a discreet pointer (e.g. a `LearnMoreLink`) + * rendered under the framing row. Plain element children; the caller decides + * whether a tile carries one, so the dense grid stays uncluttered. + */ + footer?: React.ReactNode; className?: string; } @@ -77,6 +83,7 @@ export function SparklineDeltaTile({ staleDays = null, precision = 1, provenance, + footer, className, }: SparklineDeltaTileProps) { const { t } = useTranslations(); @@ -244,6 +251,12 @@ export function SparklineDeltaTile({ {t("dashboard.staleHint", { count: staleDays })} )} + + {footer ? ( +
+ {footer} +
+ ) : null} ); } diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 2aed2b17b..00e400389 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -14,6 +14,7 @@ import { import { TileHeader } from "@/components/insights/tile-header"; import { SectionHeading } from "@/components/insights/section-heading"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; +import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { CoverageMeter } from "./coverage-meter"; import { ProvenanceExplainer } from "./provenance-explainer"; import { METRIC_PROVENANCE } from "./standards"; @@ -289,6 +290,7 @@ function BaselineTile({ provenance={ } + footer={} /> ); diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index e71a86bc1..cbc1535fc 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -6,6 +6,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; +import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; @@ -117,6 +118,9 @@ export function GlucoseClinicalPanel() {
+
+ +
); @@ -237,6 +241,7 @@ export function GlucoseClinicalPanel() { ) : null} + ); diff --git a/src/components/insights/recovery/resilience-tile.tsx b/src/components/insights/recovery/resilience-tile.tsx index 4851e3a0d..e2b5422b5 100644 --- a/src/components/insights/recovery/resilience-tile.tsx +++ b/src/components/insights/recovery/resilience-tile.tsx @@ -12,6 +12,7 @@ import { CardTitle, } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; +import { LearnMoreLink } from "@/components/ui/learn-more-link"; /** * v1.19.2 — calm readout for Oura's daily resilience level. @@ -129,6 +130,7 @@ export function ResilienceTile() { {t(TREND_KEYS[trendKey])}

) : null} + ); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 0db4c7ed4..8d6fe38b3 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; @@ -227,6 +228,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {

{marker.context}

) : null} + + {isLoading ? (