mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
5f253f7d09
commit
bf1c052797
9 files changed
+656
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user