diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx index fec827e45..d516d91f6 100644 --- a/src/app/insights/ecg/__tests__/page.test.tsx +++ b/src/app/insights/ecg/__tests__/page.test.tsx @@ -76,6 +76,26 @@ describe("/insights/ecg page (H1)", () => { expect(html).not.toContain(" { + // The teaser on `/insights` caps at five; this dedicated page must not. + // An account with many strips reaches all of them here, or the older ones + // become unreachable with no pagination behind them. Eight in, eight out. + const recordings = Array.from({ length: 8 }, (_, index) => ({ + ...seededRecording(), + id: `rec-${index}`, + recordedAt: new Date(Date.UTC(2026, 6, 10 - index, 8, 0)).toISOString(), + })); + const html = render({ recordings, hasRecordings: true }); + + const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length; + expect(rows).toBe(8); + for (const rec of recordings) { + expect(html, `${rec.id} must be reachable on the full page`).toContain( + `/insights/ecg/${rec.id}`, + ); + } + }); + it("carries no disclaimer paragraph above the list", () => { // Two muted lines used to lead this page — one from the shell's own // explainer slot and one hand-rolled beneath it, both saying HealthLog diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 7d7d1dc9e..1f7f54db6 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -9,6 +9,9 @@ import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +// Pure constant — importing it here does NOT pull the dynamically-loaded ECG +// component into this bundle, which is exactly why it lives in the lib module. +import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-classification"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; @@ -527,7 +530,7 @@ export default function InsightsPageClient() { "health-status": , breathing: , "labs-changes": , - ecg: , + ecg: , }; const orderedSectionIds = orderedVisibleSectionIds(layout); diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx index eb663f15d..8dff451da 100644 --- a/src/components/insights/__tests__/ecg-section.test.tsx +++ b/src/components/insights/__tests__/ecg-section.test.tsx @@ -28,7 +28,7 @@ vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: unknown) => useQueryMock(opts), })); -const { EcgSection, ECG_LIST_LIMIT } = await import("../ecg-section"); +const { EcgSection, ECG_OVERVIEW_LIMIT } = await import("../ecg-section"); type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem; const IRREGULAR_REC: EcgRecordingListItem = { @@ -53,11 +53,12 @@ const NORMAL_REC: EcgRecordingListItem = { function renderSection( data: { recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined, + props: { limit?: number } = {}, ) { useQueryMock.mockReturnValue({ data, isLoading: false }); return renderToStaticMarkup( - + , ); } @@ -99,30 +100,52 @@ describe("", () => { ); }); - it("caps the list at the most recent handful", () => { - // Eight strips in, five out — and the five are the ones the route - // handed back first, which it orders `recordedAt desc`. - const recordings = Array.from({ length: 8 }, (_, index) => ({ + const eightRecordings = () => + Array.from({ length: 8 }, (_, index) => ({ ...NORMAL_REC, id: `ecg_${index}`, recordedAt: new Date(Date.UTC(2026, 5, 20 - index, 9, 15)).toISOString(), })); - const html = renderSection({ recordings, hasRecordings: true }); - expect(ECG_LIST_LIMIT).toBe(5); - expect(countRows(html)).toBe(ECG_LIST_LIMIT); - for (const kept of recordings.slice(0, ECG_LIST_LIMIT)) { + it("caps the OVERVIEW teaser at the most recent handful", () => { + // The teaser passes `limit`. Eight strips in, five out — and the five are + // the ones the route handed back first, which it orders `recordedAt desc`. + const recordings = eightRecordings(); + const html = renderSection( + { recordings, hasRecordings: true }, + { limit: ECG_OVERVIEW_LIMIT }, + ); + + expect(ECG_OVERVIEW_LIMIT).toBe(5); + expect(countRows(html)).toBe(ECG_OVERVIEW_LIMIT); + for (const kept of recordings.slice(0, ECG_OVERVIEW_LIMIT)) { expect(html, `${kept.id} should be listed`).toContain( `/insights/ecg/${kept.id}`, ); } - for (const dropped of recordings.slice(ECG_LIST_LIMIT)) { - expect(html, `${dropped.id} is past the cap`).not.toContain( + for (const dropped of recordings.slice(ECG_OVERVIEW_LIMIT)) { + expect(html, `${dropped.id} is past the teaser cap`).not.toContain( `/insights/ecg/${dropped.id}`, ); } }); + it("renders every recording when NO limit is given (the full page)", () => { + // The dedicated `/insights/ecg` page passes no `limit`, so an account with + // many strips reaches all of them — the cap is a teaser affordance, never + // a wall on the full surface. Eight seeded, eight rendered, every one + // reachable by its own address. + const recordings = eightRecordings(); + const html = renderSection({ recordings, hasRecordings: true }); + + expect(countRows(html)).toBe(8); + for (const rec of recordings) { + expect(html, `${rec.id} must be reachable on the full page`).toContain( + `/insights/ecg/${rec.id}`, + ); + } + }); + it("opens the recording's own address when it has a waveform", () => { const html = renderSection({ recordings: [IRREGULAR_REC], diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index ab6ed1669..eee82e990 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -12,10 +12,16 @@ import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; import { ecgResultLabel, + ECG_OVERVIEW_LIMIT, type EcgClassification, } from "@/lib/insights/ecg-classification"; import { InsightSectionCard } from "./insight-section-card"; +// Re-exported so the tests (and any teaser call site) can keep importing the +// overview cap from the component they are exercising; the value itself lives +// in the React-free lib module. +export { ECG_OVERVIEW_LIMIT }; + /** * v1.28.50 — ECG recording list. * @@ -38,12 +44,6 @@ import { InsightSectionCard } from "./insight-section-card"; * when the user has no recordings — never an empty / alarming card. */ -/** - * How many strips the list paints. The API already caps its own read at 200; - * this is the reading limit, not the fetch limit. - */ -export const ECG_LIST_LIMIT = 5; - export interface EcgRecordingListItem { id: string; recordedAt: string; @@ -72,12 +72,20 @@ interface EcgSectionProps { * teaser keeps its own heading unchanged. */ hideHeading?: boolean; + /** + * Cap the number of strips shown. The overview teaser passes + * `ECG_OVERVIEW_LIMIT`; the dedicated `/insights/ecg` page passes nothing, + * so it renders the full list. Omitted = no cap: a full surface must never + * silently hide a recording behind a limit with no way to reach it. + */ + limit?: number; } export function EcgSection({ enabled = true, className, hideHeading = false, + limit, }: EcgSectionProps) { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -98,9 +106,11 @@ export function EcgSection({ // Data-availability gate — never paint an empty card. if (isLoading || !data || !data.hasRecordings) return null; - // The route hands them back most-recent-first; the slice is the reading - // limit on top of that, so the newest strips are the ones on screen. - const recordings = data.recordings.slice(0, ECG_LIST_LIMIT); + // The route hands them back most-recent-first. A `limit` (the overview + // teaser) keeps the newest few; no limit (the dedicated page) shows them + // all, so an older strip is never made unreachable. + const recordings = + limit != null ? data.recordings.slice(0, limit) : data.recordings; return (
= { IRREGULAR: "insights.ecg.result.irregular",