diff --git a/messages/de.json b/messages/de.json index 358cf70bc..743eecfdc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2760,7 +2760,8 @@ }, "valuesBack": "Zurück zu Insights", "valuesBackToMetric": "Zurück zu {metric}", - "scoresBack": "Zurück zu Insights" + "scoresBack": "Zurück zu Insights", + "ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts. HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert." }, "sleep": { "title": "Schlaf", @@ -3899,7 +3900,10 @@ "recordingsMany": "{count} Aufzeichnungen vorhanden.", "latestResult": "Letztes Geräte-Ergebnis: {result}.", "cta": "Aufzeichnungen ansehen" - } + }, + "emptyTitle": "Noch keine EKG-Aufzeichnungen", + "emptyDescription": "EKG-Aufzeichnungen werden von einem kompatiblen Einkanal-Gerät synchronisiert. Sobald dein Gerät eine Aufzeichnung überträgt, erscheint sie hier.", + "emptyCta": "Gerät verbinden" }, "intradayPulse": { "title": "Der Verlauf deines Tages", @@ -3918,7 +3922,8 @@ "evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.", "night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung." } - } + }, + "navEcg": "EKG" }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/en.json b/messages/en.json index 75b88cdbe..60fa17d56 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2760,7 +2760,8 @@ }, "valuesBack": "Back to insights", "valuesBackToMetric": "Back to {metric}", - "scoresBack": "Back to insights" + "scoresBack": "Back to insights", + "ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result. HealthLog shows the trace and the recording — it does not interpret the waveform." }, "sleep": { "title": "Sleep", @@ -3899,7 +3900,10 @@ "recordingsMany": "{count} recordings on file.", "latestResult": "Latest device result: {result}.", "cta": "View recordings" - } + }, + "emptyTitle": "No ECG recordings yet", + "emptyDescription": "ECG recordings sync from a compatible single-lead device. Once your device uploads a recording, it appears here.", + "emptyCta": "Connect a device" }, "intradayPulse": { "title": "Your day's shape", @@ -3918,7 +3922,8 @@ "evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.", "night": "A sustained elevated-at-rest stretch overnight — possibly some tension." } - } + }, + "navEcg": "ECG" }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/es.json b/messages/es.json index e853ccc0c..30ed1b10d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2760,7 +2760,8 @@ }, "valuesBack": "Volver a Insights", "valuesBackToMetric": "Volver a {metric}", - "scoresBack": "Volver a Insights" + "scoresBack": "Volver a Insights", + "ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo. HealthLog muestra el trazado y el registro, pero no interpreta la onda." }, "sleep": { "title": "Sueño", @@ -3899,7 +3900,10 @@ "recordingsMany": "{count} registros guardados.", "latestResult": "Último resultado del dispositivo: {result}.", "cta": "Ver registros" - } + }, + "emptyTitle": "Aún no hay registros de ECG", + "emptyDescription": "Los registros de ECG se sincronizan desde un dispositivo de una derivación compatible. En cuanto tu dispositivo suba un registro, aparecerá aquí.", + "emptyCta": "Conectar un dispositivo" }, "intradayPulse": { "title": "La forma de tu día", @@ -3918,7 +3922,8 @@ "evening": "Un tramo elevado en reposo al anochecer; posiblemente algo de tensión.", "night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión." } - } + }, + "navEcg": "ECG" }, "thresholds": { "defaultLabel": "Por defecto", diff --git a/messages/fr.json b/messages/fr.json index aebef9f44..7187478d0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2760,7 +2760,8 @@ }, "valuesBack": "Retour aux Insights", "valuesBackToMetric": "Retour à {metric}", - "scoresBack": "Retour aux Insights" + "scoresBack": "Retour aux Insights", + "ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil. HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe." }, "sleep": { "title": "Sommeil", @@ -3899,7 +3900,10 @@ "recordingsMany": "{count} enregistrements disponibles.", "latestResult": "Dernier résultat de l’appareil : {result}.", "cta": "Voir les enregistrements" - } + }, + "emptyTitle": "Aucun enregistrement ECG pour l'instant", + "emptyDescription": "Les enregistrements ECG se synchronisent depuis un appareil à dérivation unique compatible. Dès que votre appareil transfère un enregistrement, il apparaît ici.", + "emptyCta": "Connecter un appareil" }, "intradayPulse": { "title": "Le rythme de votre journée", @@ -3918,7 +3922,8 @@ "evening": "Une période élevée au repos ce soir — peut-être un peu de tension.", "night": "Une période élevée au repos cette nuit — peut-être un peu de tension." } - } + }, + "navEcg": "ECG" }, "thresholds": { "defaultLabel": "Par défaut", diff --git a/messages/it.json b/messages/it.json index 7f5c1e4ca..308233826 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2760,7 +2760,8 @@ }, "valuesBack": "Torna a Insights", "valuesBackToMetric": "Torna a {metric}", - "scoresBack": "Torna a Insights" + "scoresBack": "Torna a Insights", + "ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo. HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda." }, "sleep": { "title": "Sonno", @@ -3899,7 +3900,10 @@ "recordingsMany": "{count} registrazioni disponibili.", "latestResult": "Ultimo risultato del dispositivo: {result}.", "cta": "Vedi registrazioni" - } + }, + "emptyTitle": "Ancora nessuna registrazione ECG", + "emptyDescription": "Le registrazioni ECG si sincronizzano da un dispositivo a singola derivazione compatibile. Non appena il dispositivo carica una registrazione, comparirà qui.", + "emptyCta": "Collega un dispositivo" }, "intradayPulse": { "title": "L'andamento della tua giornata", @@ -3918,7 +3922,8 @@ "evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.", "night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione." } - } + }, + "navEcg": "ECG" }, "thresholds": { "defaultLabel": "Predefinito", diff --git a/messages/pl.json b/messages/pl.json index ffab80ed2..0f6a7dd5d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2760,7 +2760,8 @@ }, "valuesBack": "Powrót do Insights", "valuesBackToMetric": "Powrót do {metric}", - "scoresBack": "Powrót do Insights" + "scoresBack": "Powrót do Insights", + "ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia. HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej." }, "sleep": { "title": "Sen", @@ -3899,7 +3900,10 @@ "recordingsMany": "Zapisy w kartotece: {count}.", "latestResult": "Ostatni wynik urządzenia: {result}.", "cta": "Zobacz zapisy" - } + }, + "emptyTitle": "Brak zapisów EKG", + "emptyDescription": "Zapisy EKG synchronizują się z kompatybilnego urządzenia jednoodprowadzeniowego. Gdy urządzenie prześle zapis, pojawi się tutaj.", + "emptyCta": "Połącz urządzenie" }, "intradayPulse": { "title": "Przebieg Twojego dnia", @@ -3918,7 +3922,8 @@ "evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.", "night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie." } - } + }, + "navEcg": "EKG" }, "thresholds": { "defaultLabel": "Domyślne", diff --git a/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx new file mode 100644 index 000000000..70452e7b1 --- /dev/null +++ b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.30 (UX/IA audit H1) — `` now mounts on the resting-pulse + * and HRV sub-pages (previously only on `/insights/pulse`), threaded through + * ``. The pointer self-gates on + * recordings, so the shared `insightsEcgList` cache is pre-seeded with one + * recording to exercise the mounted path. + */ + +vi.mock("next/dynamic", () => ({ + default: () => { + const Stub = () =>
; + Stub.displayName = "HealthChartStub"; + return Stub; + }, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), +})); + +const analyticsMock = vi.fn(); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => analyticsMock(), +})); + +const metricStatusMock = vi.fn(); +vi.mock("@/hooks/use-insight-status", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args), + }; +}); + +import RestingPulsePage from "@/app/insights/resting-pulse/page"; +import HrvPage from "@/app/insights/hrv/page"; + +function render(node: React.ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Seed the ECG list the cross-link reads so its data-availability gate opens. + client.setQueryData(queryKeys.insightsEcgList(), { + recordings: [{ id: "a", classification: null }], + hasRecordings: true, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +beforeEach(() => { + analyticsMock.mockReset(); + metricStatusMock.mockReset(); + // Data-bearing branch for both metric pages. + analyticsMock.mockReturnValue({ + data: { + summaries: { + RESTING_HEART_RATE: { count: 5 }, + HEART_RATE_VARIABILITY: { count: 5 }, + }, + }, + isEmpty: false, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); +}); + +describe("EcgCrossLink mount on Heart sub-pages (H1)", () => { + it("mounts the ECG cross-link on the resting-pulse page", () => { + const html = render(); + expect(html).toContain('data-slot="ecg-cross-link"'); + expect(html).toContain('href="/insights#ecg"'); + }); + + it("mounts the ECG cross-link on the HRV page", () => { + const html = render(); + expect(html).toContain('data-slot="ecg-cross-link"'); + expect(html).toContain('href="/insights#ecg"'); + }); +}); diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx new file mode 100644 index 000000000..8ddba695e --- /dev/null +++ b/src/app/insights/ecg/__tests__/page.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.30 (UX/IA audit H1) — `/insights/ecg` routed sub-page. + * + * The page reuses `` (verbatim, `hideHeading` set so the shell + * owns the heading) and self-gates to an empty state when the account has no + * recordings. `useAuth` is mocked; the shared `insightsEcgList` cache is + * pre-seeded so both the page probe and the section read the same payload. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: null }), +})); + +import InsightsEcgPage from "../page"; + +interface EcgRecord { + id: string; + recordedAt: string; + durationSeconds: number | null; + samplingFrequency: number; + sampleCount: number; + averageHeartRate: number | null; + lead: string | null; + classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; + source: string; + hasWaveform: boolean; +} + +function seededRecording(): EcgRecord { + return { + id: "rec-1", + recordedAt: "2026-07-10T08:00:00.000Z", + durationSeconds: 30, + samplingFrequency: 300, + sampleCount: 9000, + averageHeartRate: 62, + lead: null, + classification: null, + source: "withings", + hasWaveform: true, + }; +} + +function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKeys.insightsEcgList(), data); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("/insights/ecg page (H1)", () => { + it("renders the EcgSection with its disclaimer when recordings exist", () => { + const html = render({ + recordings: [seededRecording()], + hasRecordings: true, + }); + // The reused section + its load-bearing non-diagnostic disclaimer render. + expect(html).toContain('data-slot="ecg-section"'); + expect(html).toContain('data-slot="ecg-card"'); + expect(html).toContain('data-slot="ecg-disclaimer"'); + // The shell owns the page heading (`

`); the section's own + // `` (`

`) is suppressed via `hideHeading`. + expect(html).toContain('id="insights-subpage-title"'); + expect(html).not.toContain(" { + const html = render({ recordings: [], hasRecordings: false }); + expect(html).not.toContain('data-slot="ecg-section"'); + expect(html).toContain("No ECG recordings yet"); + // The empty-state CTA points at the integrations surface. + expect(html).toContain('href="/settings/integrations"'); + }); +}); diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx new file mode 100644 index 000000000..c8c9a8284 --- /dev/null +++ b/src/app/insights/ecg/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { Activity } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { Button } from "@/components/ui/button"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; +import { EcgSection } from "@/components/insights/ecg-section"; + +/** + * v1.30 — `/insights/ecg` (UX/IA audit finding H1). + * + * Routed home for the ECG recording surface. Before this page the only + * inbound pointer to a user's ECGs was a cross-link card on the spot-pulse + * page; hiding the overview ECG teaser in "Anpassen" left no route back at + * all. This page gives ECG its own address, reached from the Heart-group + * pill in the tab strip (gated on `hasRecordings`) and the metric catalog. + * + * The surface itself is the existing ``, reused verbatim — its + * non-diagnostic disclaimer + waveform logic are load-bearing and untouched. + * We only suppress its internal `` (via `hideHeading`) because + * `` already renders the page heading, mirroring how every other + * routed sub-page lets the shell own the `

`. + * + * The overview `` teaser on `/insights` stays as-is. + */ +export default function InsightsEcgPage() { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + + // Reuse the SAME query cell `` reads (`insightsEcgList`) so the + // page and the section share one cache entry. The page only needs the + // `hasRecordings` flag to choose between the empty state and the section. + const { data, isLoading } = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), + enabled: isAuthenticated, + }); + + // Data-availability empty state — a direct URL hit (or deleted recordings) + // lands here without a section to show. Mirrors the sub-page convention: + // a calm one-line scaffold + a connect-a-device CTA, never a blank surface. + // While the probe is in flight we render nothing extra (the section self- + // gates too), so first paint stays quiet rather than flashing an empty card. + if (!isLoading && data && !data.hasRecordings) { + return ( + + } + title={t("insights.ecg.emptyTitle")} + description={t("insights.ecg.emptyDescription")} + cta={ + + } + coachPrefill={null} + /> + + ); + } + + return ( + + + + ); +} diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx index 353d8804f..fca82fbe7 100644 --- a/src/app/insights/hrv/page.tsx +++ b/src/app/insights/hrv/page.tsx @@ -3,6 +3,7 @@ import { Activity } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; /** * v1.4.32 — `/insights/hrv`. @@ -33,6 +34,9 @@ export default function InsightsHrvPage() { emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?" + // S10 / H1 — device-attributed pointer into the ECG viewer, in the + // HRV / heart context. Self-gates to nothing without recordings. + afterAssessment={} /> ); } diff --git a/src/app/insights/resting-pulse/page.tsx b/src/app/insights/resting-pulse/page.tsx index f78e24699..fa7efe387 100644 --- a/src/app/insights/resting-pulse/page.tsx +++ b/src/app/insights/resting-pulse/page.tsx @@ -3,6 +3,7 @@ import { Heart } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -30,6 +31,9 @@ export default function InsightsRestingHrPage() { emptyStateIcon={} emptyStateCtaType={null} coachPrefill={t("insights.restingHr.coachPrefill")} + // S10 / H1 — device-attributed pointer into the ECG viewer, in the + // resting-HR context. Self-gates to nothing without recordings. + afterAssessment={} /> ); } diff --git a/src/components/insights/__tests__/insights-tab-strip-ecg.test.tsx b/src/components/insights/__tests__/insights-tab-strip-ecg.test.tsx new file mode 100644 index 000000000..e9eca6d3c --- /dev/null +++ b/src/components/insights/__tests__/insights-tab-strip-ecg.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.30 (UX/IA audit H1) — the ECG entry in the Heart-group tab strip. + * + * ECG carries no `MeasurementType`, so it can't ride the metric + * `availability` model; the strip gates it on the dedicated + * `hasEcgRecordings` signal instead. The load-bearing product rule under + * test: the Heart group (hence the ECG entry) appears ONLY when the account + * has recordings — we never surface a data-less ECG target on the busy nav. + * + * The ECG child lives inside the Heart popover, which Radix renders only on + * open (portal); SSR markup therefore can't show the child label directly. + * These tests pin the gate through the Heart group's PRESENCE with no other + * heart-metric data, so the group appears solely because of ECG. + */ + +vi.mock("next/navigation", () => ({ + usePathname: () => "/insights", +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn() } })); + +import { InsightsTabStrip } from "../insights-tab-strip"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" — ECG gate (H1)", () => { + it("hides the Heart group (no ECG pill) when the account has no recordings", () => { + // No availability + no ECG → only the always-present pills, no Heart group. + const html = render(); + expect(html).not.toContain('data-group="heart"'); + }); + + it("hides the ECG pill when `hasEcgRecordings` is absent (default off, first paint)", () => { + const html = render(); + expect(html).not.toContain('data-group="heart"'); + }); + + it("shows the Heart group for an ECG-only account (recordings, no other heart data)", () => { + // An ECG-only device has no pulse / resting-HR / HRV rows, so no + // Heart-metric pill would light the group — the group appears solely + // because the ECG fallback emits it. + const html = render(); + expect(html).toContain('data-group="heart"'); + }); +}); diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index 6a5b4afb5..506d36655 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -82,9 +82,21 @@ function isNonNormal(classification: EcgClassification): boolean { interface EcgSectionProps { enabled?: boolean; className?: string; + /** + * v1.30 — suppress the internal `` when the section is + * hosted on the routed `/insights/ecg` sub-page, whose `` + * already renders the page `

`. Defaults to `false` so the overview + * teaser keeps its own heading unchanged. Purely presentational — the + * non-diagnostic disclaimer + waveform logic are untouched. + */ + hideHeading?: boolean; } -export function EcgSection({ enabled = true, className }: EcgSectionProps) { +export function EcgSection({ + enabled = true, + className, + hideHeading = false, +}: EcgSectionProps) { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); @@ -123,7 +135,12 @@ export function EcgSection({ enabled = true, className }: EcgSectionProps) { aria-label={t("insights.ecg.sectionTitle")} className={cn("scroll-mt-24 space-y-3", className)} > - + {!hideHeading && ( + + )}
` here so the ECG pointer + * trails the assessment, matching the placement it has on `/insights/pulse`. + * Self-gating nodes only (the cross-link un-mounts without recordings), so + * the empty / loading / error branches skip it and no void card appears. + */ + afterAssessment?: ReactNode; } export function HealthKitMetricPage({ @@ -198,6 +207,7 @@ export function HealthKitMetricPage({ statFractionDigits, statMedianLabel, afterChart, + afterAssessment, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -397,6 +407,9 @@ export function HealthKitMetricPage({ enabled={!isEmpty} /> ) : null} + {/* v1.30 (H1) — foot-of-spine slot, after the assessment card. The + resting-pulse + HRV pages mount the ECG cross-link here. */} + {afterAssessment} ); } diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index c5ad3679d..5e7d0dff0 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -116,6 +116,20 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // degrades from "enable" → "no rows yet" → the three-card spine. const nutrientsEnabled = user?.modules?.nutrients === true; + // v1.30 (UX/IA audit H1) — ECG recording presence for the Heart-group ECG + // pill. ECG carries no `MeasurementType`, so the metric `availability` + // model can't gate it; it rides its own `hasRecordings` probe instead. The + // query shares `queryKeys.insightsEcgList()` with `` and the + // ECG page, so the flag lands once per route (dedup-shared, no extra + // traffic). Undefined until it resolves → the pill stays off on first paint, + // exactly like the metric pills light up as their summaries land. + const ecgProbe = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), + enabled: isAuthenticated, + }); + const hasEcgRecordings = ecgProbe.data?.hasRecordings === true; + // v1.4.31 — memoise the `availability` prop so an unchanged // payload doesn't recreate the object on every cache-write of // analytics or comprehensive. The strip is wrapped in @@ -175,6 +189,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { visibleTileIds={visibleTileIds} tileOrder={tileOrder} modules={user?.modules} + hasEcgRecordings={hasEcgRecordings} /> {children}
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 75e9c1ff4..f6b780430 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -121,6 +121,20 @@ export interface InsightsTabStripProps { * pill — fail-open, matching the gate's default-on contract. */ modules?: InsightsModuleMap; + /** + * v1.30 (UX/IA audit H1) — true when the account has at least one ECG + * recording. ECG carries NO `MeasurementType`, so it can't ride the + * `availability` / `SUB_PAGE_METRIC` model like every metric pill; it is + * threaded here as a dedicated data-only signal instead. When true, an + * "ECG" child is appended to the Heart group's popover (and the Heart group + * is emitted even for an ECG-only device with no other heart-metric data). + * When false / absent, no ECG pill renders — we never surface a data-less + * ECG target on the busy nav (the metric catalog is the empty-state home). + * Absent until the shell's `insightsEcgList` probe resolves, so first paint + * is unchanged — a data-only gate, exactly like the other availability + * inputs. + */ + hasEcgRecordings?: boolean; } /** @@ -145,10 +159,26 @@ type TabEntry = labelKey: string; /** Translation key for the popover header. */ headerKey: string; - /** Visible sub-pages inside the group (post-availability gating). */ - children: Array<{ href: string; labelKey: string; slug: SubPageSlug }>; + /** + * Visible sub-pages inside the group (post-availability gating). `slug` + * is optional because non-metric children (the ECG entry on the Heart + * group) have no `SubPageSlug` — they render from `href` + `labelKey` + * alone and are injected outside the slug-driven availability filter. + */ + children: Array<{ href: string; labelKey: string; slug?: SubPageSlug }>; }; +/** + * v1.30 (UX/IA audit H1) — the ECG entry injected into the Heart group's + * popover when the account has recordings. Not a `SubPageSlug`: ECG carries no + * `MeasurementType`, so it lives on its own routed page (`/insights/ecg`) and + * is gated on `hasEcgRecordings`, never on the metric-availability model. + */ +const ECG_TAB_CHILD = { + href: `${INSIGHTS_OVERVIEW_PATH}/ecg`, + labelKey: "insights.navEcg", +} as const; + /** * Slug → (label key, gating metric) mapping. Keeping the metric here * means the tab strip and the empty-state gates on each sub-page can @@ -442,6 +472,7 @@ function buildTabs( visibleTileIds: ReadonlySet | undefined, tileOrder: ReadonlyMap | undefined, modules: InsightsModuleMap | undefined, + hasEcgRecordings: boolean | undefined, ): TabEntry[] { // v1.4.36 W4d — strict gating contract: a sub-page only surfaces a // pill when the availability inputs CONFIRM the metric has at @@ -537,7 +568,11 @@ function buildTabs( (tileOrder.get(b) ?? Number.MAX_SAFE_INTEGER), ) : SUB_PAGE_GROUP_ORDER[group]; - const children = orderedChildren + const children: Array<{ + href: string; + labelKey: string; + slug?: SubPageSlug; + }> = orderedChildren .filter( (childSlug) => visibleSet.has(childSlug) && !PINNED_SUB_PAGE_SLUGS.has(childSlug), @@ -547,6 +582,11 @@ function buildTabs( href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`, labelKey: SUB_PAGE_TABS[childSlug].labelKey, })); + // v1.30 (H1) — ECG is a non-metric child of the Heart group, gated on + // its own `hasEcgRecordings` signal (ECG has no `MeasurementType`). + if (group === "heart" && hasEcgRecordings) { + children.push({ ...ECG_TAB_CHILD }); + } if (children.length === 0) continue; entries.push({ kind: "group", @@ -564,6 +604,23 @@ function buildTabs( }); } + // v1.30 (H1) — an ECG-only device (a recording, but no pulse / resting-HR / + // HRV rows) never lit up any Heart-group member above, so the loop never + // emitted the group. Emit it now with ECG as its sole child so the user + // still has a nav home for their recordings. Positioned after the metric + // groups (before the catalog tail) since there is no member slug to anchor + // its natural position to. + if (hasEcgRecordings && !emittedGroups.has("heart")) { + emittedGroups.add("heart"); + entries.push({ + kind: "group", + group: "heart", + labelKey: SUB_PAGE_GROUP_META.heart.labelKey, + headerKey: SUB_PAGE_GROUP_META.heart.headerKey, + children: [{ ...ECG_TAB_CHILD }], + }); + } + // 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 @@ -587,6 +644,7 @@ function InsightsTabStripImpl({ visibleTileIds, tileOrder, modules, + hasEcgRecordings, }: InsightsTabStripProps) { const { t } = useTranslations(); const pathname = usePathname(); @@ -603,8 +661,15 @@ function InsightsTabStripImpl({ // "Anpassen". The shell memoises both props so an unchanged layout // doesn't churn the array. const tabs = useMemo( - () => buildTabs(availability, visibleTileIds, tileOrder, modules), - [availability, visibleTileIds, tileOrder, modules], + () => + buildTabs( + availability, + visibleTileIds, + tileOrder, + modules, + hasEcgRecordings, + ), + [availability, visibleTileIds, tileOrder, modules, hasEcgRecordings], ); // Fire a toast on the falling edge of `regenerating`. Same rising-edge ref diff --git a/src/components/settings/__tests__/insights-pill-order-ecg.test.tsx b/src/components/settings/__tests__/insights-pill-order-ecg.test.tsx new file mode 100644 index 000000000..214673c84 --- /dev/null +++ b/src/components/settings/__tests__/insights-pill-order-ecg.test.tsx @@ -0,0 +1,109 @@ +/** + * v1.30 (UX/IA audit H1) — the ECG row in the Insights sub-page manager. + * + * ECG has no `MeasurementType` and therefore no sub-page tile; its overview + * presence is the `"ecg"` SECTION. The manager surfaces it as a Heart-group + * row so hiding the ECG surface is reversible from the same place. The eye + * reflects (and toggles) the `"ecg"` section's `visible` flag. + * + * SSR-only suite per project convention: static markup pins the rendered eye + * state. + */ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + DEFAULT_INSIGHTS_LAYOUT, + type InsightsLayout, +} from "@/lib/insights-layout"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null, isAuthenticated: true }), +})); + +const layoutSpy = vi.fn< + () => { layout: InsightsLayout; isLoading: boolean; isSuccess: boolean } +>(() => ({ + layout: DEFAULT_INSIGHTS_LAYOUT, + isLoading: false, + isSuccess: true, +})); +vi.mock("@/hooks/use-insights-layout", () => ({ + useInsightsLayoutQuery: () => layoutSpy(), +})); + +import { InsightsPillOrderSection } from "../insights-pill-order-section"; + +function layoutWithEcgVisible(visible: boolean): InsightsLayout { + return { + version: DEFAULT_INSIGHTS_LAYOUT.version, + sections: DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => + s.id === "ecg" ? { ...s, visible } : { ...s }, + ), + tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((t) => ({ ...t })), + }; +} + +beforeEach(() => { + layoutSpy.mockImplementation(() => ({ + layout: DEFAULT_INSIGHTS_LAYOUT, + isLoading: false, + isSuccess: true, + })); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function render(): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" — ECG row (H1)", () => { + it("lists an ECG row inside the Heart group", () => { + const html = render(); + // The ECG row rides the Heart group and carries an eye toggle. + expect(html).toContain('data-group="heart"'); + expect(html).toMatch( + /data-tile="ecg"[\s\S]*?data-slot="insights-pill-order-eye"/, + ); + }); + + it("reflects the ECG section visible (default layout)", () => { + const html = render(); + const ecgRow = html.match( + /]*data-tile="ecg"[\s\S]*?<\/div>\s*<\/div>/, + ); + expect(ecgRow).not.toBeNull(); + expect(ecgRow![0]).toContain('data-visible="true"'); + }); + + it("reflects a hidden ECG section from the saved layout", () => { + layoutSpy.mockImplementation(() => ({ + layout: layoutWithEcgVisible(false), + isLoading: false, + isSuccess: true, + })); + const html = render(); + const ecgRow = html.match( + /]*data-tile="ecg"[\s\S]*?<\/div>\s*<\/div>/, + ); + expect(ecgRow).not.toBeNull(); + expect(ecgRow![0]).toContain('data-visible="false"'); + }); +}); diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index ad64c66e8..cebb797dc 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -108,14 +108,31 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { // ref) keeps the reseed render-safe and idempotent while leaving in-flight // drag edits untouched: the baseline only advances when the *server* order // differs from what we last seeded from. - const serverSignature = layoutSignature(layout.tiles); + // v1.30 (UX/IA audit H1) — ECG has no `MeasurementType` and therefore no + // sub-page tile; its overview presence is the `"ecg"` SECTION. Surface it + // here as a Heart-group row so hiding the ECG surface is reversible from the + // same place the user manages every other Heart detail page. The eye toggles + // the `"ecg"` section's `visible` flag (the exact flag a user flips in the + // overview "Anpassen" editor). The tab-strip ECG pill stays a permanent, + // data-gated route back and is deliberately NOT gated on this flag, so + // hiding the overview card can never orphan a user's recordings again. + const serverEcgVisible = + layout.sections.find((s) => s.id === "ecg")?.visible ?? true; + // Fold the ECG section flag into the re-seed fingerprint so a server-side + // change to it (e.g. saved from the overview arrange editor) re-seeds the + // ECG draft too, not just tile edits. + const serverSignature = `${layoutSignature(layout.tiles)}|ecg:${ + serverEcgVisible ? 1 : 0 + }`; const [seededSignature, setSeededSignature] = useState(serverSignature); const [draftTiles, setDraftTiles] = useState(() => [...layout.tiles].sort((a, b) => a.order - b.order), ); + const [ecgVisibleDraft, setEcgVisibleDraft] = useState(serverEcgVisible); if (isSuccess && serverSignature !== seededSignature) { setSeededSignature(serverSignature); setDraftTiles([...layout.tiles].sort((a, b) => a.order - b.order)); + setEcgVisibleDraft(serverEcgVisible); } const sensors = useSensors( @@ -127,11 +144,19 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { const saveMutation = useMutation({ mutationFn: async (tiles: InsightsTileConfig[]) => { + // Preserve the overview section order/visibility verbatim — this surface + // owns the PILL order. The ONE exception is the `"ecg"` section, whose + // eye lives in this manager (H1); override it only when the user actually + // flipped it so an unchanged save stays a section no-op. + const sections = + ecgVisibleDraft !== serverEcgVisible + ? layout.sections.map((s) => + s.id === "ecg" ? { ...s, visible: ecgVisibleDraft } : s, + ) + : layout.sections; return apiPut("/api/insights/layout", { version: 2, - // Preserve the overview section order/visibility verbatim — this - // surface owns the PILL order only. - sections: layout.sections, + sections, tiles, }); }, @@ -200,8 +225,10 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { } const dirty = useMemo( - () => layoutSignature(layout.tiles) !== layoutSignature(draftTiles), - [layout.tiles, draftTiles], + () => + layoutSignature(layout.tiles) !== layoutSignature(draftTiles) || + ecgVisibleDraft !== serverEcgVisible, + [layout.tiles, draftTiles, ecgVisibleDraft, serverEcgVisible], ); return ( @@ -284,6 +311,22 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {

+ {/* v1.30 (H1) — ECG rides the Heart group as a non-sortable row: + it has no `MeasurementType` and no tile order, so there is + nothing to drag. The eye toggles the `"ecg"` overview section + (see the draft plumbing above). */} + {group === "heart" && ( +
+ setEcgVisibleDraft((v) => !v)} + /> +
+ )} ))} @@ -295,6 +338,61 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { const DRAG_HANDLE_CLASS = "text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex h-11 w-11 shrink-0 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9 sm:before:absolute sm:before:inset-[-6px] sm:before:content-['']"; +const EYE_TOGGLE_CLASS = + "text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-11 w-11 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9"; + +/** + * v1.30 (H1) — the ECG row in the Heart group. Unlike {@link SortablePillRow} + * it is not draggable (ECG has no tile order), so it carries an aligned spacer + * where the drag handle sits on its siblings and only the eye toggle. The eye + * flips the `"ecg"` overview section's visibility. + */ +function EcgManagerRow({ + title, + visible, + disabled, + showLabel, + hideLabel, + onToggle, +}: { + title: string; + visible: boolean; + disabled: boolean; + showLabel: string; + hideLabel: string; + onToggle: () => void; +}) { + return ( +
+ {/* Alignment spacer standing in for the sortable rows' drag handle. */} +
+ ); +} + function SortablePillRow({ id, title, diff --git a/src/lib/insights/metric-catalog.ts b/src/lib/insights/metric-catalog.ts index 1f6e07d83..7d147dbd4 100644 --- a/src/lib/insights/metric-catalog.ts +++ b/src/lib/insights/metric-catalog.ts @@ -202,7 +202,9 @@ const ECG_ENTRY: CatalogEntry = { nameKey: "insights.ecg.sectionTitle", sourceKey: "metricCatalog.ecg.source", kind: "metric", - href: INSIGHTS_OVERVIEW_PATH, + // v1.30 (H1) — ECG now has its own routed page; the catalog "View" button + // points there instead of the overview teaser anchor. + href: `${INSIGHTS_OVERVIEW_PATH}/ecg`, connectable: true, };