mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user