feat(insights): add a metric catalog for discoverability

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 23:50:06 +02:00
1 parent 5f253f7d09
commit bf1c052797
9 files changed
+656

No files matched your search

+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+297
View File
@@ -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
* `<EcgSection>` already reads.
*/
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
}
const GROUP_ICONS: Record<ManagerGroup, LucideIcon> = {
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 (
<Badge
variant="outline"
className="border-success/40 bg-success/15 text-success shrink-0"
>
{t("metricCatalog.statusPresent")}
</Badge>
);
}
return (
<span className="text-muted-foreground shrink-0 text-xs">
{t("metricCatalog.statusAbsent")}
</span>
);
}
function CatalogRow({
entry,
present,
}: {
entry: CatalogEntry;
present: boolean | null;
}) {
const { t } = useTranslations();
if (entry.kind === "info") {
return (
<div data-slot="catalog-row-info" className="flex items-start gap-2 py-2">
<Siren className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</p>
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
</div>
</div>
);
}
const isPresent = present === true;
return (
<div
data-slot="catalog-row"
data-metric-id={entry.id}
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</span>
<StatusBadge present={isPresent} />
</div>
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
</div>
<div className="shrink-0">
{isPresent && entry.href ? (
<Button size="sm" variant="ghost" asChild>
<Link href={entry.href}>
{t("metricCatalog.viewCta")}
<ChevronRight className="size-3.5" />
</Link>
</Button>
) : !isPresent && entry.connectable ? (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("metricCatalog.connectCta")}
</Link>
</Button>
) : !isPresent && entry.manualHref && entry.manualCtaKey ? (
<Button size="sm" variant="outline" asChild>
<Link href={entry.manualHref}>{t(entry.manualCtaKey)}</Link>
</Button>
) : null}
</div>
</div>
);
}
export default function MetricCatalogPage() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
const comprehensiveQuery = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: () => apiGet<ComprehensivePayload>("/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 (
<SubPageShell
title={t("metricCatalog.title")}
description={t("metricCatalog.description")}
>
<div className="space-y-6">
{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 (
<Card
key={group}
data-slot="catalog-group"
className="gap-2 py-3 md:py-4"
>
<CardHeader>
<TileHeader
icon={Icon}
title={t(CATALOG_GROUP_HEADER_KEYS[group])}
/>
</CardHeader>
<CardContent>
<div className="divide-border divide-y">
{entries.map((entry) => {
if (entry.id === "nutrients" && !nutrientsModuleEnabled) {
return (
<div
key={entry.id}
data-slot="catalog-row"
data-metric-id="nutrients"
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("metricCatalog.statusModuleOff")}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("nutrients.page.moduleOffDescription")}
</p>
</div>
<div className="shrink-0">
<Button size="sm" variant="outline" asChild>
<Link href="/settings/modules">
{t("nutrients.page.moduleOffCta")}
</Link>
</Button>
</div>
</div>
);
}
return (
<CatalogRow
key={entry.id}
entry={entry}
present={isPresent(entry)}
/>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
</SubPageShell>
);
}
@@ -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;
}
+244
View File
@@ -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.<slug>.emptyState.description` (the `HealthKitMetricPage`
* pattern) or `insights.emptyState.<key>.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<ManagerGroup, string> = {
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.<camelSlug>.emptyState.description` (the `HealthKitMetricPage`
* convention) — the seven specialised metrics carry it at
* `insights.emptyState.<key>.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/<slug>/page.tsx`.
*/
const SOURCE_KEY_OVERRIDES: Partial<Record<SubPageSlug, string>> = {
"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<SubPageSlug> = new Set([
"mood",
"medications",
"pain",
"grip-strength",
"waist-to-height",
]);
const MANUAL_CTA: Partial<
Record<SubPageSlug, { href: string; ctaKey: string }>
> = {
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.<camel>.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<ManagerGroup, CatalogEntry[]>();
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;
}