mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
e8f4bb3dba
commit
adf80da989
19 files changed
+713
-32
No files matched your search
+8
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user