From bf1c052797b8c1605e612aa749fc6c7aecf4e588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:49:09 +0200 Subject: [PATCH] feat(insights): add a metric catalog for discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metrics with zero data stay hidden from the dashboard and Insights nav by design — that keeps the daily views uncluttered but leaves no reachable surface telling a sparse-device-kit account which capabilities exist at all. Add /insights/catalog: every metric domain grouped by category, each row showing whether the account has data for it and what would supply it, with a connect CTA into settings/integrations when absent and device-sourceable. Reuses the existing tab-strip gating (hasMetricData over the shared analytics/comprehensive/workouts/nutrients reads) and the per-metric empty-state copy already shown on each sub-page, so names, sources, and presence can't drift from what's shown elsewhere. Adds one quiet, always-visible pill at the tail of the Insights tab strip as the entry point — no new loud nav destination. --- messages/de.json | 17 + messages/en.json | 17 + messages/es.json | 17 + messages/fr.json | 17 + messages/it.json | 17 + messages/pl.json | 17 + src/app/insights/catalog/page.tsx | 297 ++++++++++++++++++ .../insights/insights-tab-strip.tsx | 13 + src/lib/insights/metric-catalog.ts | 244 ++++++++++++++ 9 files changed, 656 insertions(+) create mode 100644 src/app/insights/catalog/page.tsx create mode 100644 src/lib/insights/metric-catalog.ts diff --git a/messages/de.json b/messages/de.json index 44ba5ee3b..918bf3db3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -586,6 +586,22 @@ "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert" }, + "metricCatalog": { + "title": "Was HealthLog erfassen kann", + "description": "Alle Messwert-Bereiche, die HealthLog unterstützt — auch die, für die noch keine Daten vorliegen — und was sie liefert.", + "statusPresent": "Erfasst", + "statusAbsent": "Noch nicht erfasst", + "statusModuleOff": "Aus", + "connectCta": "Quelle verbinden", + "viewCta": "Ansehen", + "ecg": { + "source": "Einkanal-EKG-Streifen von einer kompatiblen Uhr — aktuell Withings ScanWatch — werden automatisch synchronisiert." + }, + "watchdog": { + "title": "Automatische Überwachung", + "description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr sowie Warnungen bei Abweichungen von deiner Baseline — und zeigt eine Karte nur, wenn etwas auffällt. Keine Karte ist ein gutes Zeichen, kein fehlendes Feature." + } + }, "mood": { "title": "Stimmung", "subtitle": "Stimmungsverlauf und Einträge", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "vor {count} Tag", "relativeDaysAgoOther": "vor {count} Tagen", "navOverview": "Übersicht", + "navCatalog": "Alle Messwerte", "navBloodPressure": "Blutdruck", "navWeight": "Gewicht", "navPulse": "Puls", diff --git a/messages/en.json b/messages/en.json index 6346aa9bb..fe4d78afc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -586,6 +586,22 @@ "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score" }, + "metricCatalog": { + "title": "What HealthLog can track", + "description": "Every metric domain HealthLog supports, whether or not you have data for it yet — and what would supply it.", + "statusPresent": "Tracked", + "statusAbsent": "Not tracked yet", + "statusModuleOff": "Off", + "connectCta": "Connect a source", + "viewCta": "View", + "ecg": { + "source": "Single-lead ECG strips from a compatible watch — Withings ScanWatch today — synced automatically." + }, + "watchdog": { + "title": "Automatic screening", + "description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, plus baseline-drift alerts — and surfaces a card only when something stands out. No card showing is a good sign, not a missing feature." + } + }, "mood": { "title": "Mood", "subtitle": "Mood tracker and entries", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "{count} day ago", "relativeDaysAgoOther": "{count} days ago", "navOverview": "Overview", + "navCatalog": "All metrics", "navBloodPressure": "Blood Pressure", "navWeight": "Weight", "navPulse": "Pulse", diff --git a/messages/es.json b/messages/es.json index 36f54cb24..0b958601a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -586,6 +586,22 @@ "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI" }, + "metricCatalog": { + "title": "Lo que HealthLog puede registrar", + "description": "Todos los ámbitos de métricas que admite HealthLog, tengas datos o no todavía, y qué los proporciona.", + "statusPresent": "Registrado", + "statusAbsent": "Aún sin registrar", + "statusModuleOff": "Desactivado", + "connectCta": "Conectar una fuente", + "viewCta": "Ver", + "ecg": { + "source": "Tiras de ECG de una derivación desde un reloj compatible — hoy, Withings ScanWatch — sincronizadas automáticamente." + }, + "watchdog": { + "title": "Supervisión automática", + "description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, además de alertas de desviación de tu valor de referencia — y solo muestra una tarjeta cuando algo destaca. Que no aparezca ninguna tarjeta es una buena señal, no una función ausente." + } + }, "mood": { "title": "Estado de ánimo", "subtitle": "Tendencia y registros de estado de ánimo", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "hace {count} día", "relativeDaysAgoOther": "hace {count} días", "navOverview": "Resumen", + "navCatalog": "Todas las métricas", "navBloodPressure": "Presión arterial", "navWeight": "Peso", "navPulse": "Pulso", diff --git a/messages/fr.json b/messages/fr.json index 14420754a..b0e846e42 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -586,6 +586,22 @@ "typeWho5Score": "Score WHO-5", "typeSciScore": "Score SCI" }, + "metricCatalog": { + "title": "Ce que HealthLog peut suivre", + "description": "Tous les domaines de mesures pris en charge par HealthLog, que tu aies déjà des données ou non — et ce qui les fournirait.", + "statusPresent": "Suivi", + "statusAbsent": "Pas encore suivi", + "statusModuleOff": "Désactivé", + "connectCta": "Connecter une source", + "viewCta": "Voir", + "ecg": { + "source": "Tracés ECG à une dérivation depuis une montre compatible — Withings ScanWatch aujourd'hui — synchronisés automatiquement." + }, + "watchdog": { + "title": "Surveillance automatique", + "description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, ainsi que des alertes d'écart par rapport à ta valeur de référence — et n'affiche une carte que si quelque chose se démarque. L'absence de carte est bon signe, pas une fonctionnalité manquante." + } + }, "mood": { "title": "Humeur", "subtitle": "Tendance et entrées d’humeur", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "il y a {count} jour", "relativeDaysAgoOther": "il y a {count} jours", "navOverview": "Vue d'ensemble", + "navCatalog": "Toutes les mesures", "navBloodPressure": "Tension artérielle", "navWeight": "Poids", "navPulse": "Pouls", diff --git a/messages/it.json b/messages/it.json index 639145a51..a349f00e3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -586,6 +586,22 @@ "typeWho5Score": "Punteggio WHO-5", "typeSciScore": "Punteggio SCI" }, + "metricCatalog": { + "title": "Cosa può monitorare HealthLog", + "description": "Tutti gli ambiti di metriche supportati da HealthLog, che tu abbia già dei dati oppure no — e cosa li fornirebbe.", + "statusPresent": "Monitorato", + "statusAbsent": "Non ancora monitorato", + "statusModuleOff": "Disattivato", + "connectCta": "Collega una fonte", + "viewCta": "Visualizza", + "ecg": { + "source": "Tracciati ECG a una derivazione da un orologio compatibile — oggi Withings ScanWatch — sincronizzati automaticamente." + }, + "watchdog": { + "title": "Monitoraggio automatico", + "description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, oltre ad avvisi di scostamento dalla tua base di riferimento — mostrando una scheda solo quando qualcosa emerge. Nessuna scheda visibile è un buon segno, non una funzione mancante." + } + }, "mood": { "title": "Umore", "subtitle": "Andamento e voci dell’umore", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "{count} giorno fa", "relativeDaysAgoOther": "{count} giorni fa", "navOverview": "Panoramica", + "navCatalog": "Tutte le metriche", "navBloodPressure": "Pressione arteriosa", "navWeight": "Peso", "navPulse": "Polso", diff --git a/messages/pl.json b/messages/pl.json index 996d46c39..e10f195fe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -586,6 +586,22 @@ "typeWho5Score": "Wynik WHO-5", "typeSciScore": "Wynik SCI" }, + "metricCatalog": { + "title": "Co HealthLog potrafi śledzić", + "description": "Wszystkie obszary metryk obsługiwane przez HealthLog — również te, dla których nie masz jeszcze danych — oraz co je dostarcza.", + "statusPresent": "Śledzone", + "statusAbsent": "Jeszcze nieśledzone", + "statusModuleOff": "Wyłączone", + "connectCta": "Połącz źródło", + "viewCta": "Zobacz", + "ecg": { + "source": "Jednoodprowadzeniowe zapisy EKG z kompatybilnego zegarka — obecnie Withings ScanWatch — synchronizowane automatycznie." + }, + "watchdog": { + "title": "Automatyczny nadzór", + "description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, a także alerty o odchyleniu od Twojej wartości bazowej — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Brak karty to dobry znak, a nie brakująca funkcja." + } + }, "mood": { "title": "Nastrój", "subtitle": "Trend i wpisy nastroju", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "{count} dzień temu", "relativeDaysAgoOther": "{count} dni temu", "navOverview": "Przegląd", + "navCatalog": "Wszystkie metryki", "navBloodPressure": "Ciśnienie krwi", "navWeight": "Waga", "navPulse": "Tętno", diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx new file mode 100644 index 000000000..aa6b0be7a --- /dev/null +++ b/src/app/insights/catalog/page.tsx @@ -0,0 +1,297 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + ChevronRight, + Droplet, + Ear, + Footprints, + HeartPulse, + Moon, + Pill, + Scale, + Siren, + Sun, + Waves, + type LucideIcon, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { useAuth } from "@/hooks/use-auth"; +import { useWorkouts } from "@/hooks/use-workouts"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { + hasMetricData, + type InsightInputs, +} from "@/lib/insights/metric-availability"; +import { + CATALOG_GROUP_HEADER_KEYS, + CATALOG_GROUP_ORDER, + catalogEntriesByGroup, + type CatalogEntry, +} from "@/lib/insights/metric-catalog"; +import type { ManagerGroup } from "@/lib/insights/sub-page-metric"; + +/** + * `/insights/catalog` — "what can HealthLog track" (2026-07-17 + * discoverability audit, recommendation R1). + * + * A single, on-purpose surface: every metric domain HealthLog supports, + * grouped, each row showing its name, whether THIS account has data for + * it yet, what supplies it (device-concrete, vendor-neutral), and — when + * absent and a device could supply it — a quiet CTA into + * `/settings/integrations`. This is deliberately NOT surfaced in the + * daily nav: metrics with no data stay hidden from the dashboard / + * Insights tab strip by design (`metric-availability.ts` — "a metric + * with zero observations doesn't surface a navigation target the user + * can't act on"), so an account with a sparse device kit would otherwise + * have no reachable evidence a capability exists at all. This page is + * that evidence, reached only when the user navigates here on purpose + * (the quiet "All metrics" pill at the tail of the Insights tab strip, + * see `insights-tab-strip.tsx`). + * + * Presence per row reuses the EXACT gating inputs the tab strip / nav + * pills already compute (`hasMetricData` over the shared analytics + + * comprehensive + workouts + nutrients reads) — this page adds no new + * server route and no new derivation of "does the user have this metric". + * The ECG row is the one exception: ECG carries no `MeasurementType`, so + * its presence comes from the same `hasRecordings` probe the overview's + * `` already reads. + */ + +interface ComprehensivePayload { + moodSummary: { count: number } | null; + medications: Array<{ id: string }>; +} + +const GROUP_ICONS: Record = { + vitals: Activity, + body: Scale, + activity: Footprints, + sleep: Moon, + cardiovascular: HeartPulse, + hearing: Ear, + environment: Sun, + metabolic: Droplet, + mood: Waves, + events: Pill, +}; + +function StatusBadge({ present }: { present: boolean }) { + const { t } = useTranslations(); + if (present) { + return ( + + {t("metricCatalog.statusPresent")} + + ); + } + return ( + + {t("metricCatalog.statusAbsent")} + + ); +} + +function CatalogRow({ + entry, + present, +}: { + entry: CatalogEntry; + present: boolean | null; +}) { + const { t } = useTranslations(); + + if (entry.kind === "info") { + return ( +
+ +
+

+ {t(entry.nameKey)} +

+

{t(entry.sourceKey)}

+
+
+ ); + } + + const isPresent = present === true; + + return ( +
+
+
+ + {t(entry.nameKey)} + + +
+

{t(entry.sourceKey)}

+
+
+ {isPresent && entry.href ? ( + + ) : !isPresent && entry.connectable ? ( + + ) : !isPresent && entry.manualHref && entry.manualCtaKey ? ( + + ) : null} +
+
+ ); +} + +export default function MetricCatalogPage() { + const { t } = useTranslations(); + const { isAuthenticated, user } = useAuth(); + + const analyticsQuery = useAnalyticsQuery({ slice: "summaries" }); + + const comprehensiveQuery = useQuery({ + queryKey: queryKeys.insightsComprehensive(), + queryFn: () => apiGet("/api/insights/comprehensive"), + enabled: isAuthenticated, + }); + + const workoutsProbe = useWorkouts({ limit: 1 }); + + const nutrientsModuleEnabled = user?.modules?.nutrients === true; + const nutrientsProbe = useQuery({ + queryKey: queryKeys.nutrientIntake(1), + queryFn: () => + apiGet<{ nutrients: Array<{ nutrient: string }> }>( + "/api/nutrients?days=1", + ), + enabled: isAuthenticated && nutrientsModuleEnabled, + }); + + const ecgQuery = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), + enabled: isAuthenticated, + }); + + const availability: InsightInputs | undefined = isAuthenticated + ? { + summaries: analyticsQuery.data?.summaries, + hasMood: (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0, + hasMedication: (comprehensiveQuery.data?.medications?.length ?? 0) > 0, + hasWorkouts: (workoutsProbe.data?.workouts.length ?? 0) > 0, + hasNutrients: (nutrientsProbe.data?.nutrients.length ?? 0) > 0, + } + : undefined; + + const grouped = catalogEntriesByGroup(); + + function isPresent(entry: CatalogEntry): boolean | null { + if (entry.kind === "info") return null; + if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true; + if (!availability || !entry.metric) return false; + return hasMetricData(entry.metric, availability); + } + + return ( + +
+ {CATALOG_GROUP_ORDER.map((group) => { + const entries = grouped.get(group) ?? []; + if (entries.length === 0) return null; + const Icon = GROUP_ICONS[group]; + // Nutrients rides the metabolic group but needs a module-off + // branch (B1 — the tab-strip pill requires the module ON before + // it ever probes for rows, so a module-off account never saw + // the in-context enable CTA; this row is the fix). + return ( + + + + + +
+ {entries.map((entry) => { + if (entry.id === "nutrients" && !nutrientsModuleEnabled) { + return ( +
+
+
+ + {t(entry.nameKey)} + + + {t("metricCatalog.statusModuleOff")} + +
+

+ {t("nutrients.page.moduleOffDescription")} +

+
+
+ +
+
+ ); + } + return ( + + ); + })} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index d2fd0060b..6299c83f8 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -551,6 +551,19 @@ function buildTabs( labelKey: SUB_PAGE_TABS[slug].labelKey, }); } + + // 2026-07-17 discoverability audit (R1) — a quiet, always-present pill + // trailing every other pill, linking to the "what can HealthLog track" + // catalog. Unlike every metric pill above, it carries no availability + // gate (like Overview / Recovery): the catalog's whole purpose is + // listing the metrics that AREN'T visible elsewhere yet, so gating its + // own entry point on data would defeat it. + entries.push({ + kind: "link", + href: `${INSIGHTS_OVERVIEW_PATH}/catalog`, + labelKey: "insights.navCatalog", + }); + return entries; } diff --git a/src/lib/insights/metric-catalog.ts b/src/lib/insights/metric-catalog.ts new file mode 100644 index 000000000..a88c07ecc --- /dev/null +++ b/src/lib/insights/metric-catalog.ts @@ -0,0 +1,244 @@ +/** + * v1.28.x — the metric catalog data model. + * + * Backs `/insights/catalog`: a single, on-purpose surface answering "what + * can HealthLog track, and what feeds it" for the ~90 metric types the + * primary Insights nav deliberately hides while they carry zero data (see + * `metric-availability.ts` docblock — "a metric with zero observations + * doesn't surface a navigation target the user can't act on"). That floor + * is correct for the daily nav; it also means an account with a sparse + * device kit has NO reachable surface that says a capability exists at + * all (2026-07-17 discoverability audit, "the availability floor" finding). + * This module is the one place that lists every capability regardless of + * whether the account has data for it yet. + * + * Every entry's name + source copy is SOURCED from the existing, already- + * localised per-metric empty-state strings — never hand-duplicated here. + * `insights..emptyState.description` (the `HealthKitMetricPage` + * pattern) or `insights.emptyState..description` (the seven + * specialised metrics) already say honestly, per metric, what supplies it + * ("HRV samples land here once Apple Watch — or a compatible Withings + * device — starts syncing nightly readings."). Reusing them means the + * catalog cannot drift from the copy shown on the metric's own empty + * state, and adding a new sub-page's source line is a one-place change + * (write its `emptyState.description`; the catalog picks it up via the + * same derivation the tab strip already uses for the pill label). + */ +import type { InsightMetric } from "@/lib/insights/metric-availability"; +import { + INSIGHTS_OVERVIEW_PATH, + MANAGER_GROUP_ORDER, + SUB_PAGE_MANAGER_GROUP, + SUB_PAGE_SLUGS, + type ManagerGroup, + type SubPageSlug, +} from "@/lib/insights/sub-page-metric"; +import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; + +export type CatalogEntryKind = "metric" | "info"; + +export interface CatalogEntry { + /** Stable row id — the sub-page slug for routed metrics, a bespoke id otherwise. */ + id: string; + group: ManagerGroup; + /** i18n key resolving to the display name (reused from the tab-strip pill label). */ + nameKey: string; + /** i18n key resolving to the one-line "what supplies this" copy. */ + sourceKey: string; + kind: CatalogEntryKind; + /** `kind === "metric"` — the gating metric `hasMetricData` reads. Absent for the ECG row, which resolves presence from its own probe. */ + metric?: InsightMetric; + /** Route to the metric's own page, once it has data (or always, for ECG). */ + href?: string; + /** Whether an absent row offers the "connect a source" CTA into `/settings/integrations`. */ + connectable: boolean; + /** Manual-only metrics (no device path) offer a log CTA instead. */ + manualHref?: string; + manualCtaKey?: string; +} + +/** + * Group section heading — the descriptive `.header` copy where the tab + * strip's grouped popover carries one (e.g. "Vital signs" vs the pill's + * short "Vitals"), the plain manager-group label otherwise. Reused + * verbatim from the existing tab-strip / pill-order-manager namespace — + * zero new group-heading strings. + */ +export const CATALOG_GROUP_HEADER_KEYS: Record = { + vitals: "insights.tabStrip.vitalsParent.header", + body: "insights.tabStrip.bodyParent.header", + activity: "insights.tabStrip.activityParent.header", + sleep: "insights.editMode.groupSleep", + cardiovascular: "insights.tabStrip.cardiovascularParent.header", + hearing: "insights.tabStrip.hearingParent.header", + environment: "insights.tabStrip.environmentParent.header", + metabolic: "insights.tabStrip.metabolicParent.header", + mood: "insights.editMode.groupMood", + events: "insights.editMode.groupEvents", +}; + +export const CATALOG_GROUP_ORDER: readonly ManagerGroup[] = MANAGER_GROUP_ORDER; + +/** + * The eight slugs whose source copy does NOT live at + * `insights..emptyState.description` (the `HealthKitMetricPage` + * convention) — the seven specialised metrics carry it at + * `insights.emptyState..description` instead, and nutrients (event- + * driven, module-gated) carries it under its own page namespace. Every + * value below is an EXISTING key already shown on that metric's own + * empty-state branch — see `src/app/insights//page.tsx`. + */ +const SOURCE_KEY_OVERRIDES: Partial> = { + "blood-pressure": "insights.emptyState.bloodPressure.description", + pulse: "insights.emptyState.pulse.description", + weight: "insights.emptyState.weight.description", + bmi: "insights.emptyState.bmi.description", + sleep: "insights.emptyState.sleep.description", + mood: "insights.emptyState.mood.description", + medications: "insights.emptyState.medication.description", + nutrients: "nutrients.page.emptyDescription", + // The nav-key → emptyState-key derivation (below) strips the `nav` + // prefix and lowercases the first letter; these two sub-pages' empty + // state key is shorter than their nav label suffix (`navPainNrs` → + // `pain`, not `painNrs`; `navWaistCircumference` → `waist`, not + // `waistCircumference`), so they need an explicit override. + pain: "insights.pain.emptyState.description", + waist: "insights.waist.emptyState.description", +}; + +/** + * Metrics with no device path at all — manual entry is the only source. + * Mood and medications are inherently self-report (no sensor produces a + * mood score or an adherence event). Pain, grip strength, and waist-to- + * height ratio are ALSO manual-only despite sitting beside device-fed + * siblings in their group: none carries an Apple Health mapping + * (`apple-health-mapping.ts`) or a Withings/wearable source — pain is a + * 0–10 self-rating, grip strength is read off a hand dynamometer, and + * waist-to-height is logged directly (not derived like BMI). Cross- + * checked against every routed sub-page's `emptyStateCtaType` + + * `emptyState.description` copy so this list can't silently drift from + * what the metric's own empty state actually offers. + */ +const MANUAL_ONLY_SLUGS: ReadonlySet = new Set([ + "mood", + "medications", + "pain", + "grip-strength", + "waist-to-height", +]); + +const MANUAL_CTA: Partial< + Record +> = { + mood: { href: "/mood", ctaKey: "insights.emptyState.mood.cta" }, + medications: { + href: "/medications", + ctaKey: "insights.emptyState.medication.cta", + }, + pain: { + href: "/measurements?add=PAIN_NRS", + ctaKey: "insights.pain.emptyState.cta", + }, + "grip-strength": { + href: "/measurements?add=GRIP_STRENGTH", + ctaKey: "insights.gripStrength.emptyState.cta", + }, + "waist-to-height": { + href: "/measurements?add=WAIST_TO_HEIGHT", + ctaKey: "insights.waistToHeight.emptyState.cta", + }, +}; + +/** + * Derive the `insights..emptyState.*` namespace segment from a + * `SUB_PAGE_TABS` nav label key (`insights.navOxygenSaturation` → + * `oxygenSaturation`). Mirrors the pill label exactly, so a slug whose nav + * key and empty-state key already agree (41 of the 49 routed sub-pages) + * needs no entry in `SOURCE_KEY_OVERRIDES` at all. + */ +function camelFromNavLabelKey(labelKey: string): string { + const suffix = labelKey.replace(/^insights\.nav/, ""); + return suffix.charAt(0).toLowerCase() + suffix.slice(1); +} + +function buildMetricEntries(): CatalogEntry[] { + return SUB_PAGE_SLUGS.map((slug) => { + const tab = SUB_PAGE_TABS[slug]; + const group = SUB_PAGE_MANAGER_GROUP[slug]; + const camel = camelFromNavLabelKey(tab.labelKey); + const sourceKey = + SOURCE_KEY_OVERRIDES[slug] ?? `insights.${camel}.emptyState.description`; + const manual = MANUAL_CTA[slug]; + const entry: CatalogEntry = { + id: slug, + group, + nameKey: tab.labelKey, + sourceKey, + kind: "metric", + metric: tab.metric, + href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`, + connectable: !MANUAL_ONLY_SLUGS.has(slug), + }; + if (manual) { + entry.manualHref = manual.href; + entry.manualCtaKey = manual.ctaKey; + } + return entry; + }); +} + +/** + * ECG recordings (v1.28.50) carry no `MeasurementType` — the section reads + * a dedicated `hasRecordings` flag off `GET /api/insights/ecg`, not + * `summaries[…].count` — so it is not a `SUB_PAGE_SLUGS` entry and needs + * its own catalog row. Source is Withings ScanWatch today (see + * `.planning/ios-coord/v1.28.50-server-to-ios-ecg.md`) — Apple Watch ECG + * (`HKElectrocardiogramType`) is explicitly deferred in the HealthKit + * ingest map, so the copy names ScanWatch only rather than overclaiming. + */ +const ECG_ENTRY: CatalogEntry = { + id: "ecg", + group: "cardiovascular", + nameKey: "insights.ecg.sectionTitle", + sourceKey: "metricCatalog.ecg.source", + kind: "metric", + href: INSIGHTS_OVERVIEW_PATH, + connectable: true, +}; + +/** + * Discoverability audit finding A5 — the alert-style watchdogs (rhythm- + * event notifications, breathing-disturbance screening, baseline-drift + * health status) are CORRECT to stay unmounted when there is nothing to + * flag ("no alert" must not render an alarming empty shell), but nothing + * anywhere told the user they were running. This is the "single line" + * fix the audit recommended in place of per-card empty states: an + * always-shown info row, no present/absent gate, no CTA. + */ +const WATCHDOG_ENTRY: CatalogEntry = { + id: "watchdog", + group: "cardiovascular", + nameKey: "metricCatalog.watchdog.title", + sourceKey: "metricCatalog.watchdog.description", + kind: "info", + connectable: false, +}; + +export const METRIC_CATALOG_ENTRIES: readonly CatalogEntry[] = [ + ...buildMetricEntries(), + ECG_ENTRY, + WATCHDOG_ENTRY, +]; + +/** Group the catalog entries in canonical manager-group order. */ +export function catalogEntriesByGroup(): ReadonlyMap< + ManagerGroup, + CatalogEntry[] +> { + const map = new Map(); + for (const group of MANAGER_GROUP_ORDER) map.set(group, []); + for (const entry of METRIC_CATALOG_ENTRIES) { + map.get(entry.group)?.push(entry); + } + return map; +}