diff --git a/messages/de.json b/messages/de.json
index 44bc796c9..9eb67bb5a 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 d33948ea6..18535e2c4 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 3af706e90..604ff582d 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 d63aafdeb..22653496d 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 a29c170e1..4481f8870 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 537bd840f..7f27207f1 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/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 ? (
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}
+
+
+ );
+}
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;
+}