feat(insights): give ECG a routed home and index-surface presence

ECG recordings had no pill in the tab strip, no entry in the sub-page
manager, and no routed page — the only inbound pointer was a cross-link
card on the spot-pulse page. Hiding the overview ECG teaser left no route
back to a user's recordings.

- Add a routed `/insights/ecg` sub-page that reuses the existing ECG
  section (heading suppressed so the page shell owns the title); the
  overview teaser stays.
- Add an ECG entry to the Heart group of the tab strip, gated on a
  dedicated `hasEcgRecordings` signal since ECG carries no measurement
  type. The Heart group surfaces for an ECG-only device too. No ECG pill
  appears without recordings.
- Mount the ECG cross-link on the resting-pulse and HRV pages, matching
  its placement on the pulse page.
- List ECG in the sub-page manager under Heart, its eye toggling the ECG
  overview section so hiding it is reversible.
- Point the metric catalog's ECG row at the new page.
- Add `navEcg`, the sub-page description, and the empty-state copy across
  all locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 09:24:28 +02:00
1 parent e8f4bb3dba
commit adf80da989
19 files changed
+713 -32

No files matched your search

+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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",
@@ -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) — `<EcgCrossLink>` now mounts on the resting-pulse
* and HRV sub-pages (previously only on `/insights/pulse`), threaded through
* `<HealthKitMetricPage afterAssessment>`. 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 = () => <div data-slot="healthkit-chart-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<typeof import("@/hooks/use-insight-status")>();
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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
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(<RestingPulsePage />);
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(<HrvPage />);
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights#ecg"');
});
});
@@ -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 `<EcgSection>` (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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<InsightsEcgPage />
</I18nProvider>
</QueryClientProvider>,
);
}
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 (`<h1>`); the section's own
// `<SectionHeading>` (`<h2>`) is suppressed via `hideHeading`.
expect(html).toContain('id="insights-subpage-title"');
expect(html).not.toContain("<h2");
});
it("shows the empty state (no section) when the account has no recordings", () => {
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"');
});
});
+82
View File
@@ -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 `<EcgSection>`, reused verbatim — its
* non-diagnostic disclaimer + waveform logic are load-bearing and untouched.
* We only suppress its internal `<SectionHeading>` (via `hideHeading`) because
* `<SubPageShell>` already renders the page heading, mirroring how every other
* routed sub-page lets the shell own the `<h1>`.
*
* The overview `<EcgSection>` teaser on `/insights` stays as-is.
*/
export default function InsightsEcgPage() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
// Reuse the SAME query cell `<EcgSection>` 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 (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<MetricEmptyState
icon={<Activity className="size-6" />}
title={t("insights.ecg.emptyTitle")}
description={t("insights.ecg.emptyDescription")}
cta={
<Button size="sm" asChild>
<Link href="/settings/integrations">
{t("insights.ecg.emptyCta")}
</Link>
</Button>
}
coachPrefill={null}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<EcgSection enabled={isAuthenticated} hideHeading />
</SubPageShell>
);
}
+4
View File
@@ -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={<Activity className="size-6" />}
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={<EcgCrossLink />}
/>
);
}
+4
View File
@@ -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={<Heart className="size-6" />}
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={<EcgCrossLink />}
/>
);
}
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<InsightsTabStrip> — 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(<InsightsTabStrip hasEcgRecordings={false} />);
expect(html).not.toContain('data-group="heart"');
});
it("hides the ECG pill when `hasEcgRecordings` is absent (default off, first paint)", () => {
const html = render(<InsightsTabStrip />);
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(<InsightsTabStrip hasEcgRecordings={true} />);
expect(html).toContain('data-group="heart"');
});
});
+19 -2
View File
@@ -82,9 +82,21 @@ function isNonNormal(classification: EcgClassification): boolean {
interface EcgSectionProps {
enabled?: boolean;
className?: string;
/**
* v1.30 — suppress the internal `<SectionHeading>` when the section is
* hosted on the routed `/insights/ecg` sub-page, whose `<SubPageShell>`
* already renders the page `<h1>`. 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)}
>
<SectionHeading icon={Activity} title={t("insights.ecg.sectionTitle")} />
{!hideHeading && (
<SectionHeading
icon={Activity}
title={t("insights.ecg.sectionTitle")}
/>
)}
<div
data-slot="ecg-card"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
@@ -174,6 +174,15 @@ export interface HealthKitMetricPageProps {
* omits it and renders byte-identically.
*/
afterChart?: ReactNode;
/**
* v1.30 (UX/IA audit H1) — optional content rendered at the very foot of the
* data-bearing spine, AFTER the metric-status assessment card. The
* resting-pulse + HRV pages mount `<EcgCrossLink>` 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}
</SubPageShell>
);
}
@@ -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 `<EcgSection>` 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}
</div>
+70 -5
View File
@@ -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<string> | undefined,
tileOrder: ReadonlyMap<string, number> | 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
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<InsightsPillOrderSection id="insights-pill-order" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InsightsPillOrderSection> — 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(
/<div[^>]*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(
/<div[^>]*data-tile="ecg"[\s\S]*?<\/div>\s*<\/div>/,
);
expect(ecgRow).not.toBeNull();
expect(ecgRow![0]).toContain('data-visible="false"');
});
});
@@ -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<InsightsTileConfig[]>(() =>
[...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<InsightsLayout>("/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 }) {
</div>
</SortableContext>
</DndContext>
{/* 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" && (
<div className="mt-1.5">
<EcgManagerRow
title={t("insights.navEcg")}
visible={ecgVisibleDraft}
disabled={busy}
showLabel={t("insights.editMode.show")}
hideLabel={t("insights.editMode.hide")}
onToggle={() => setEcgVisibleDraft((v) => !v)}
/>
</div>
)}
</div>
))}
</div>
@@ -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 (
<div
data-slot="insights-pill-order-row"
data-tile="ecg"
className="border-border bg-background/30 flex items-center gap-2 rounded-md border px-2 py-1.5"
>
{/* Alignment spacer standing in for the sortable rows' drag handle. */}
<span
aria-hidden="true"
className="inline-flex h-11 w-11 shrink-0 sm:h-9 sm:w-9"
/>
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={visible}
aria-label={`${visible ? hideLabel : showLabel} — ${title}`}
title={visible ? hideLabel : showLabel}
data-slot="insights-pill-order-eye"
data-visible={visible ? "true" : "false"}
className={EYE_TOGGLE_CLASS}
>
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
);
}
function SortablePillRow({
id,
title,
+3 -1
View File
@@ -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,
};