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] 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; +}