mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(LEARNUI): Learn-links registry, component, and surface wiring
This commit is contained in:
commit
03e1935c8a
14 files changed
+269
-6
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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é",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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 })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{footer ? (
|
||||
<div className="mt-2" data-slot="sparkline-delta-tile-footer">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
<MetricProvenance metric={metric} provenance={data.provenance} />
|
||||
}
|
||||
footer={<LearnMoreLink concept={tileId} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<div className="mt-4">
|
||||
<LabsCrossLink />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<LearnMoreLink concept="BLOOD_GLUCOSE" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -237,6 +241,7 @@ export function GlucoseClinicalPanel() {
|
||||
) : null}
|
||||
|
||||
<LabsCrossLink />
|
||||
<LearnMoreLink concept="BLOOD_GLUCOSE" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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])}
|
||||
</p>
|
||||
) : null}
|
||||
<LearnMoreLink concept="RESILIENCE" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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 }) {
|
||||
<p className="text-muted-foreground text-sm">{marker.context}</p>
|
||||
) : null}
|
||||
|
||||
<LearnMoreLink concept="LAB_BIOMARKER" />
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -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 `<a>` (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 (
|
||||
<a
|
||||
href={guide.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-slot="learn-more-link"
|
||||
aria-label={`${label}: ${guide.title}`}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 transition-colors hover:underline",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, LearnGuide> = 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<Record<LearnConcept, LearnSlug>> =
|
||||
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 `<LearnMoreLink>`, which renders nothing on `null`.
|
||||
*/
|
||||
export function learnLinkForMetric(metricId: string): LearnGuide | null {
|
||||
const slug = (LEARN_LINKS as Record<string, LearnSlug | undefined>)[metricId];
|
||||
if (slug == null) return null;
|
||||
return GUIDE_BY_SLUG.get(slug) ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user