diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89de3e04e..ee80675e3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21436,7 +21436,8 @@ paths: per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day - default. Not delegable: a grant is not an introduction to the other people in the household." + default. Whether the list stopped at its row ceiling is `meta.truncated` (it was a `truncated` field in `data` + before v1.43.7). Not delegable: a grant is not an introduction to the other people in the household." responses: "200": description: The activity, and how far back it can see. @@ -81390,15 +81391,9 @@ components: description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows. - truncated: - type: boolean - description: True when the read reached its row ceiling, so `entries` is the most recent activity inside the window - rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest - row read as the beginning. False means the list is everything inside the window. required: - entries - retentionDays - - truncated additionalProperties: false description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention. RecordActivityEntry: diff --git a/messages/de.json b/messages/de.json index b2f45cd89..a71da0818 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "Hier stehen die letzten {count}; ältere Einträge sind nicht aufgeführt.", "save": "Speichern", "conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.", "cancel": "Abbrechen", @@ -12327,8 +12328,7 @@ "loadError": "Die Aktivität konnte nicht geladen werden.", "opened": "{name} hat deine Akte {count}× geöffnet", "acted": "{name} hat etwas in deiner Akte geändert", - "deletedAccount": "Ein gelöschtes Konto", - "truncated": "Diese Liste zeigt die neueste Aktivität, nicht die gesamte." + "deletedAccount": "Ein gelöschtes Konto" }, "delegate": { "addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}." diff --git a/messages/en.json b/messages/en.json index b24c7614c..98a9f9034 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "Showing the latest {count}; older entries are not listed here.", "save": "Save", "conflictReloaded": "Reloaded from the server. Please make your change again.", "cancel": "Cancel", @@ -12327,8 +12328,7 @@ "loadError": "The activity could not be loaded.", "opened": "{name} opened your record {count}×", "acted": "{name} made a change in your record", - "deletedAccount": "A deleted account", - "truncated": "This list shows the most recent activity, not all of it." + "deletedAccount": "A deleted account" }, "delegate": { "addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do." diff --git a/messages/es.json b/messages/es.json index ad0911981..e601d625c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "Se muestran los últimos {count}; las entradas anteriores no aparecen aquí.", "save": "Guardar", "conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.", "cancel": "Cancelar", @@ -12327,8 +12328,7 @@ "loadError": "No se pudo cargar la actividad.", "opened": "{name} abrió tu historial {count}×", "acted": "{name} hizo un cambio en tu historial", - "deletedAccount": "Una cuenta eliminada", - "truncated": "Esta lista muestra la actividad más reciente, no toda." + "deletedAccount": "Una cuenta eliminada" }, "delegate": { "addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}." diff --git a/messages/fr.json b/messages/fr.json index c646bb00d..44e6024ed 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "Seuls les {count} derniers sont affichés ; les entrées plus anciennes ne figurent pas ici.", "save": "Enregistrer", "conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.", "cancel": "Annuler", @@ -12327,8 +12328,7 @@ "loadError": "L'activité n'a pas pu être chargée.", "opened": "{name} a ouvert votre dossier {count}×", "acted": "{name} a fait une modification dans votre dossier", - "deletedAccount": "Un compte supprimé", - "truncated": "Cette liste montre l'activité la plus récente, pas la totalité." + "deletedAccount": "Un compte supprimé" }, "delegate": { "addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées." diff --git a/messages/it.json b/messages/it.json index ed4265878..80fdced4b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "Sono mostrati gli ultimi {count}; le voci più vecchie non sono elencate qui.", "save": "Salva", "conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.", "cancel": "Annulla", @@ -12327,8 +12328,7 @@ "loadError": "Non è stato possibile caricare l'attività.", "opened": "{name} ha aperto la tua cartella {count}×", "acted": "{name} ha fatto una modifica nella tua cartella", - "deletedAccount": "Un account eliminato", - "truncated": "Questo elenco mostra l'attività più recente, non tutta." + "deletedAccount": "Un account eliminato" }, "delegate": { "addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle." diff --git a/messages/ko.json b/messages/ko.json index a4a77cdba..d224ba278 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "최근 {count}개만 표시되며, 이전 항목은 여기에 나오지 않습니다.", "save": "저장", "conflictReloaded": "서버에서 다시 불러왔어요. 변경 내용을 다시 입력해 주세요.", "cancel": "취소", @@ -12327,8 +12328,7 @@ "loadError": "활동 내역을 불러오지 못했어요.", "opened": "{name} 님이 내 기록을 {count}번 열었어요", "acted": "{name} 님이 내 기록을 수정했어요", - "deletedAccount": "삭제된 계정", - "truncated": "이 목록은 최근 활동만 보여줘요. 전부는 아니에요." + "deletedAccount": "삭제된 계정" }, "delegate": { "addOnlyNote": "이 기록에 추가할 수 있어요. 수정과 삭제는 {name} 님만 할 수 있어요." diff --git a/messages/nl.json b/messages/nl.json index 5ea71537a..b6dd5fe1e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "Alleen de laatste {count} worden getoond; oudere items staan hier niet.", "save": "Opslaan", "conflictReloaded": "Opnieuw geladen van de server. Voer je wijziging opnieuw uit.", "cancel": "Annuleren", @@ -12327,8 +12328,7 @@ "loadError": "De activiteit kon niet worden geladen.", "opened": "{name} heeft je dossier {count}× geopend", "acted": "{name} heeft iets gewijzigd in je dossier", - "deletedAccount": "Een verwijderd account", - "truncated": "Deze lijst toont de meest recente activiteit, niet alles." + "deletedAccount": "Een verwijderd account" }, "delegate": { "addOnlyNote": "Je kunt iets toevoegen aan dit dossier. Registraties wijzigen of verwijderen kan alleen {name}." diff --git a/messages/pl.json b/messages/pl.json index b41dfa858..5cd38bb74 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1,5 +1,6 @@ { "common": { + "listCapped": "Widać tylko ostatnie {count}; starsze wpisy nie są tu wymienione.", "save": "Zapisz", "conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.", "cancel": "Anuluj", @@ -12327,8 +12328,7 @@ "loadError": "Nie udało się wczytać aktywności.", "opened": "{name} otworzył twoją kartę {count}×", "acted": "{name} wprowadził zmianę w twojej karcie", - "deletedAccount": "Usunięte konto", - "truncated": "Ta lista pokazuje najnowszą aktywność, a nie całą." + "deletedAccount": "Usunięte konto" }, "delegate": { "addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}." diff --git a/src/__tests__/list-cap-reader-guard.test.ts b/src/__tests__/list-cap-reader-guard.test.ts new file mode 100644 index 000000000..3c6752832 --- /dev/null +++ b/src/__tests__/list-cap-reader-guard.test.ts @@ -0,0 +1,117 @@ +/** + * Structural guard: every list the server caps has a client that reads the + * cap. + * + * A capped list says so on the envelope (`apiListSuccess`, `meta.truncated` + * / `meta.limit`). The web client read only `data` (`apiGet`), so the signal + * reached nobody: twelve routes sent it and no surface showed it. A capped + * route is now bound to a reader, a production file outside the API tree + * that fetches the route's path through `apiGetList` / `apiPostList`, the + * one client reader of the cap, which a surface pairs with `ListCapNotice`. + * + * The routes are derived from the tree (every route file that answers with + * `apiListSuccess`), never listed by hand, so a newly capped route fails here + * until something reads its cap. Comments are stripped first; the planted + * cases prove the matchers fire. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** Capped routes with no list on screen, and why. */ +const NO_LIST_ON_SCREEN: Record = { + "/api/personal-records": + "The web reads it for one decorative badge (a record set in the last 30 days), not as a list; the native client owns the list.", +}; + +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +/** `app/api/a/[id]/b/route.ts` → `/api/a/[id]/b`. */ +function routePathOf(file: string): string { + return `/${file.replace(/^app\//, "").replace(/\/route\.tsx?$/, "")}`; +} + +/** A matcher for the route's path as a client spells it. */ +function clientPathPattern(route: string): RegExp { + const body = route + .split("/") + .map((seg) => + /^\[.+\]$/.test(seg) + ? "\\$\\{[^}]+\\}" + : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ) + .join("/"); + return new RegExp( + `\\bapi(?:Get|Post)List\\s*<[\\s\\S]{0,200}?>\\s*\\(\\s*["\`]${body}(?:[?"\`])|\\bapi(?:Get|Post)List\\s*\\(\\s*["\`]${body}(?:[?"\`])`, + ); +} + +describe("capped lists have a reader", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }).filter( + (file) => + !file.startsWith("generated/") && + !file.includes("__tests__/") && + !/\.test\.tsx?$/.test(file), + ); + const read = (file: string) => + stripComments(readFileSync(join(SRC, file), "utf8")); + + const cappedRoutes = files + .filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f)) + .filter((f) => /\bapiListSuccess\s*\(/.test(read(f))) + .map(routePathOf) + .sort(); + const clients = files + .filter((f) => !f.startsWith("app/api/")) + .map((f) => read(f)); + + it("the matcher binds a client spelling to its route", () => { + expect( + clientPathPattern("/api/medications/[id]/side-effects").test( + "apiGetList<{ items: Row[] }>(`/api/medications/${medicationId}/side-effects?from=1`)", + ), + ).toBe(true); + expect( + clientPathPattern("/api/allergies").test( + 'apiGetList("/api/allergies")', + ), + ).toBe(true); + // A plain `apiGet` reads `data` only and does not count. + expect( + clientPathPattern("/api/allergies").test( + 'apiGet("/api/allergies")', + ), + ).toBe(false); + // A longer path that shares the prefix does not count either. + expect( + clientPathPattern("/api/allergies").test( + 'apiGetList("/api/allergies/x")', + ), + ).toBe(false); + }); + + it("finds the capped routes", () => { + expect(cappedRoutes.length).toBeGreaterThanOrEqual(12); + }); + + it("binds every capped route to a client that reads its cap", () => { + const unread = cappedRoutes.filter( + (route) => + !(route in NO_LIST_ON_SCREEN) && + !clients.some((code) => clientPathPattern(route).test(code)), + ); + expect(unread).toEqual([]); + }); + + it("keeps the exemptions to routes that are still capped", () => { + for (const route of Object.keys(NO_LIST_ON_SCREEN)) { + expect(cappedRoutes).toContain(route); + } + }); +}); diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts index 8e781d144..d72b9db82 100644 --- a/src/app/api/account/activity/route.ts +++ b/src/app/api/account/activity/route.ts @@ -142,13 +142,9 @@ export const GET = apiHandler(async () => { createdAt: row.createdAt.toISOString(), })); - // Exact: one row past the cap was asked for, so a list that is exactly - // full no longer reads as cut. - const truncated = cap.truncated; - annotate({ action: { name: "sharing.activity.list" }, - meta: { entry_count: entries.length, truncated }, + meta: { entry_count: entries.length, truncated: cap.truncated }, }); return apiListSuccess( @@ -157,11 +153,9 @@ export const GET = apiHandler(async () => { // The bound the view states out loud. Resolved here so the UI never has to // hardcode a number the operator can change. retentionDays, - // Whether the list is everything inside that window, or its most recent - // hundred rows. The view says which; silence would let the oldest line - // read as the beginning. - truncated, }, + // Whether the list is everything inside that window, or its most recent + // hundred rows, rides the envelope like every capped list's (`meta`). cap, ); }); diff --git a/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx index 2f9c1a86f..6849bfbf8 100644 --- a/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx +++ b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx @@ -56,8 +56,11 @@ function render(node: React.ReactNode) { }); // 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, + data: { + recordings: [{ id: "a", classification: null }], + hasRecordings: true, + }, + cap: null, }); return renderToStaticMarkup( diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 722a0c42b..38bda883e 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -43,6 +43,7 @@ import { type CatalogEntry, } from "@/lib/insights/metric-catalog"; import type { ManagerGroup } from "@/lib/insights/sub-page-metric"; +import { useEcgList } from "@/components/insights/use-ecg-list"; /** * `/insights/catalog` — "what can HealthLog track" (2026-07-17 @@ -252,11 +253,7 @@ export default function MetricCatalogPage() { enabled: isAuthenticated && nutrientsModuleEnabled, }); - const ecgQuery = useQuery({ - queryKey: queryKeys.insightsEcgList(), - queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), - enabled: isAuthenticated, - }); + const ecgQuery = useEcgList(); const availability: InsightInputs | undefined = isAuthenticated ? { @@ -319,7 +316,7 @@ export default function MetricCatalogPage() { function isPresent(entry: CatalogEntry): boolean | null { if (entry.kind === "info") return null; - if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true; + if (entry.id === "ecg") return ecgQuery.data?.data.hasRecordings === true; if (!availability || !entry.metric) return false; return hasMetricData(entry.metric, availability); } diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx index 2698461ae..db42dbebf 100644 --- a/src/app/insights/ecg/__tests__/page.test.tsx +++ b/src/app/insights/ecg/__tests__/page.test.tsx @@ -52,7 +52,7 @@ function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); - client.setQueryData(queryKeys.insightsEcgList(), data); + client.setQueryData(queryKeys.insightsEcgList(), { data, cap: null }); return renderToStaticMarkup( diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx index 174687e2b..10cc0205a 100644 --- a/src/app/insights/ecg/page.tsx +++ b/src/app/insights/ecg/page.tsx @@ -2,17 +2,15 @@ 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 { QueryErrorCard } from "@/components/ui/query-error-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { EcgSection } from "@/components/insights/ecg-section"; +import { useEcgList } from "@/components/insights/use-ecg-list"; /** * v1.30 — `/insights/ecg` (UX/IA audit finding H1). @@ -39,11 +37,8 @@ export default function InsightsEcgPage() { // Reuse the SAME query cell `` reads (`insightsEcgList`) so the // page and the section share one cache entry. The page only needs the // `hasRecordings` flag to choose between the empty state and the section. - const { data, isLoading, isError, error, refetch } = useQuery({ - queryKey: queryKeys.insightsEcgList(), - queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), - enabled: isAuthenticated, - }); + const { data: page, isLoading, isError, error, refetch } = useEcgList(); + const data = page?.data; // A read failure must be visible, not a heading over an empty body that // reads the same as "still loading" (§6). The section teaser below unmounts diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 4742525eb..1b273ee93 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -33,6 +33,7 @@ import { useCycleInsights, useCycleProfile, } from "./use-cycle"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; /** * v1.15.0 — the cycle vertical's client orchestrator. @@ -415,9 +416,13 @@ export function CycleView() { - + + - apiGet(`/api/cycle/cycles?limit=${limit}`), + apiGetList(`/api/cycle/cycles?limit=${limit}`), staleTime: 60_000, }); } diff --git a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts index ae403d1b5..e447bc115 100644 --- a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts +++ b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts @@ -16,16 +16,22 @@ vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({}), })); +const journal = vi.hoisted(() => ({ total: 230 })); + vi.mock("@/lib/api/api-fetch", () => ({ - apiGet: async (url: string) => { + apiGet: vi.fn(), + apiGetList: async (url: string) => { calls.push(url); - // The route's own paging: 50 unless asked otherwise, from offset 0. + // The route's own paging: 50 unless asked otherwise, from offset 0, and + // the cap it sends: a row past the page exists or it does not. const params = new URL(url, "http://x").searchParams; const offset = Number(params.get("offset") ?? 0); const limit = Number(params.get("limit") ?? 50); - const total = 230; - const count = Math.max(0, Math.min(limit, total - offset)); - return Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` })); + const count = Math.max(0, Math.min(limit, journal.total - offset)); + return { + data: Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` })), + cap: { truncated: offset + count < journal.total, limit }, + }; }, apiPost: vi.fn(), apiPatch: vi.fn(), @@ -47,4 +53,16 @@ describe("useIllnessEpisodes", () => { true, ); }); + + it("stops on a full last page the route says is the end", async () => { + calls.length = 0; + journal.total = 200; + const options = useIllnessEpisodes(true) as unknown as { + queryFn: () => Promise<{ id: string }[]>; + }; + expect(await options.queryFn()).toHaveLength(200); + // Two full pages of 100, no third request for an empty one. + expect(calls).toHaveLength(2); + journal.total = 230; + }); }); diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index b4b69288a..562ba032b 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -10,7 +10,13 @@ */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { + apiDelete, + apiGet, + apiGetList, + apiPatch, + apiPost, +} from "@/lib/api/api-fetch"; import { illnessDependentKeys, invalidateKeys, @@ -37,7 +43,8 @@ const EPISODE_PAGE_SIZE = 100; * most), and the journal, its settings and every picker read this as the * whole record; a single unpaged call dropped the oldest episodes once there * were more than one page of them, and a person then found an old condition - * only through search. So this reads page after page until a short one. + * only through search. So this reads page after page until the route says + * nothing is left. */ export function useIllnessEpisodes(includeResolved = true, enabled = true) { return useQuery({ @@ -46,11 +53,13 @@ export function useIllnessEpisodes(includeResolved = true, enabled = true) { queryFn: async () => { const all: IllnessEpisodeDTO[] = []; for (let offset = 0; ; offset += EPISODE_PAGE_SIZE) { - const page = await apiGet( + const { data: page, cap } = await apiGetList( `/api/illness/episodes?includeResolved=${includeResolved}&limit=${EPISODE_PAGE_SIZE}&offset=${offset}`, ); all.push(...page); - if (page.length < EPISODE_PAGE_SIZE) return all; + // The route says whether a row past this page exists, so a last page + // that is exactly full ends the walk without one more request. + if (!cap?.truncated) return all; } }, }); diff --git a/src/components/insights/__tests__/ecg-cross-link.test.tsx b/src/components/insights/__tests__/ecg-cross-link.test.tsx index 3f1bb8b0e..838b0cd7a 100644 --- a/src/components/insights/__tests__/ecg-cross-link.test.tsx +++ b/src/components/insights/__tests__/ecg-cross-link.test.tsx @@ -36,7 +36,9 @@ interface Item { function render( data: { recordings: Item[]; hasRecordings: boolean } | undefined, ) { - useQueryMock.mockReturnValue({ data }); + useQueryMock.mockReturnValue({ + data: data ? { data, cap: null } : undefined, + }); return renderToStaticMarkup( diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx index 8dff451da..198aeb846 100644 --- a/src/components/insights/__tests__/ecg-section.test.tsx +++ b/src/components/insights/__tests__/ecg-section.test.tsx @@ -54,8 +54,12 @@ function renderSection( data: { recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined, props: { limit?: number } = {}, + cap: { truncated: boolean; limit: number } | null = null, ) { - useQueryMock.mockReturnValue({ data, isLoading: false }); + useQueryMock.mockReturnValue({ + data: data ? { data, cap } : undefined, + isLoading: false, + }); return renderToStaticMarkup( @@ -174,4 +178,18 @@ describe("", () => { expect(html).not.toContain('data-slot="ecg-disclaimer"'); expect(html).not.toContain("Single-lead ECG strips"); }); + + it("says when the full list stopped at the route's ceiling, never on the teaser", () => { + const data = { recordings: [IRREGULAR_REC], hasRecordings: true }; + const cap = { truncated: true, limit: 200 }; + expect(renderSection(data, {}, cap)).toContain( + 'data-slot="list-cap-notice"', + ); + expect(renderSection(data, { limit: 3 }, cap)).not.toContain( + 'data-slot="list-cap-notice"', + ); + expect(renderSection(data, {})).not.toContain( + 'data-slot="list-cap-notice"', + ); + }); }); diff --git a/src/components/insights/__tests__/rhythm-events-card.test.tsx b/src/components/insights/__tests__/rhythm-events-card.test.tsx index 7de61bc59..223c6cb7d 100644 --- a/src/components/insights/__tests__/rhythm-events-card.test.tsx +++ b/src/components/insights/__tests__/rhythm-events-card.test.tsx @@ -42,7 +42,10 @@ interface RhythmEvent { function renderWith( data: { events: RhythmEvent[]; hasEvents: boolean } | undefined, ) { - useQueryMock.mockReturnValue({ data, isLoading: false }); + useQueryMock.mockReturnValue({ + data: data ? { data, cap: null } : undefined, + isLoading: false, + }); return renderToStaticMarkup( diff --git a/src/components/insights/ecg-cross-link.tsx b/src/components/insights/ecg-cross-link.tsx index 15174c2a7..d0d30407c 100644 --- a/src/components/insights/ecg-cross-link.tsx +++ b/src/components/insights/ecg-cross-link.tsx @@ -2,14 +2,11 @@ import Link from "next/link"; import { ArrowRight, HeartPulse } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { TileHeader } from "@/components/insights/tile-header"; +import { useEcgList } from "./use-ecg-list"; /** * ECG cross-link, the pointer from the resting-HR / pulse context into @@ -29,18 +26,6 @@ import { TileHeader } from "@/components/insights/tile-header"; * surfaces share one cache entry with an identical response shape. */ -type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; - -interface EcgRecordingListItem { - id: string; - classification: EcgClassification; -} - -interface EcgListResponse { - recordings: EcgRecordingListItem[]; - hasRecordings: boolean; -} - /** The device verdict, surfaced verbatim and attributed to the device. */ const RESULT_LABEL_KEYS: Record = { IRREGULAR: "insights.ecg.result.irregular", @@ -54,20 +39,9 @@ interface EcgCrossLinkProps { } export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) { - const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data } = useQuery({ - queryKey: queryKeys.insightsEcgList(), - queryFn: async () => { - try { - return await apiGet("/api/insights/ecg"); - } catch { - throw new Error(t("insights.ecg.loadError")); - } - }, - enabled: enabled && isAuthenticated, - }); + const data = useEcgList(enabled).data?.data; // Data-availability gate — never paint an empty pointer. if (!data || !data.hasRecordings || data.recordings.length === 0) return null; diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index 2741a7d8c..34e724bf3 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -1,12 +1,8 @@ "use client"; import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; import { Activity, ChevronRight, HeartPulse } from "lucide-react"; -import { useAuth } from "@/hooks/use-auth"; -import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; @@ -18,6 +14,8 @@ import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit"; import { DayLinkAt } from "@/components/day/day-link"; import { InsightSectionCard } from "./insight-section-card"; +import { useEcgList } from "./use-ecg-list"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; // 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 @@ -59,11 +57,6 @@ export interface EcgRecordingListItem { hasWaveform: boolean; } -interface EcgListResponse { - recordings: EcgRecordingListItem[]; - hasRecordings: boolean; -} - interface EcgSectionProps { enabled?: boolean; className?: string; @@ -89,21 +82,11 @@ export function EcgSection({ hideHeading = false, limit, }: EcgSectionProps) { - const { isAuthenticated } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading } = useQuery({ - queryKey: queryKeys.insightsEcgList(), - queryFn: async () => { - try { - return await apiGet("/api/insights/ecg"); - } catch { - throw new Error(t("insights.ecg.loadError")); - } - }, - enabled: enabled && isAuthenticated, - }); + const { data: page, isLoading } = useEcgList(enabled); + const data = page?.data; // Data-availability gate — never paint an empty card. if (isLoading || !data || !data.hasRecordings) return null; @@ -205,6 +188,9 @@ export function EcgSection({ ); })} + {/* The teaser keeps its newest few on purpose; the full list says + when the server stopped short of the history. */} + {limit == null && } ); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 4414792c7..c04feeb8f 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -18,6 +18,7 @@ import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { queryKeys } from "@/lib/query-keys"; import type { InsightInputs } from "@/lib/insights/metric-availability"; import { apiGet } from "@/lib/api/api-fetch"; +import { useEcgList } from "@/components/insights/use-ecg-list"; /** * v1.4.25 — client shell for `src/app/insights/layout.tsx`. @@ -119,12 +120,8 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // 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; + const ecgProbe = useEcgList(); + const hasEcgRecordings = ecgProbe.data?.data.hasRecordings === true; // v1.4.31 — memoise the `availability` prop so an unchanged // payload doesn't recreate the object on every cache-write of diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx index 6c953a550..92d7ee1da 100644 --- a/src/components/insights/rhythm-events-card.tsx +++ b/src/components/insights/rhythm-events-card.tsx @@ -6,7 +6,8 @@ import type { LucideIcon } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; +import { apiGetList } from "@/lib/api/api-fetch"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; @@ -91,11 +92,17 @@ export function RhythmEventsCard({ const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError, error, refetch } = useQuery({ + const { + data: page, + isLoading, + isError, + error, + refetch, + } = useQuery({ queryKey: queryKeys.insightsRhythmEvents(), queryFn: async () => { try { - return await apiGet( + return await apiGetList( "/api/insights/rhythm-events", ); } catch { @@ -104,6 +111,7 @@ export function RhythmEventsCard({ }, enabled: enabled && isAuthenticated, }); + const data = page?.data; // A failed read is said, never swallowed: this card carries health alerts, // and silently rendering nothing on an error would hide them. @@ -186,6 +194,7 @@ export function RhythmEventsCard({ ); })} + {/* Load-bearing regulatory disclaimer — permanent, non-dismissible. Plain React text children (no markdown library — XSS rule). */} diff --git a/src/components/insights/use-ecg-list.ts b/src/components/insights/use-ecg-list.ts new file mode 100644 index 000000000..f49491178 --- /dev/null +++ b/src/components/insights/use-ecg-list.ts @@ -0,0 +1,37 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { apiGetList } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import type { EcgRecordingListItem } from "./ecg-section"; + +export interface EcgListResponse { + recordings: EcgRecordingListItem[]; + hasRecordings: boolean; +} + +/** + * The ECG list, one query cell for every reader (the section, the page, the + * cross-link, the tab strip, the catalog). The cell holds `{ data, cap }`, + * so every reader shares one shape and the cap the route sends reaches the + * list that shows it. + */ +export function useEcgList(enabled = true) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + return useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: async () => { + try { + return await apiGetList("/api/insights/ecg"); + } catch { + throw new Error(t("insights.ecg.loadError")); + } + }, + enabled: enabled && isAuthenticated, + }); +} diff --git a/src/components/medications/__tests__/side-effects-section.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx index 03df2c4dc..180f997fd 100644 --- a/src/components/medications/__tests__/side-effects-section.test.tsx +++ b/src/components/medications/__tests__/side-effects-section.test.tsx @@ -444,6 +444,7 @@ describe(" — timeline rows", () => { describe(" — paging", () => { function sideEffectsResponse( items: Array<{ id: string; occurredAt: string }>, + truncated = false, ): Response { return new Response( JSON.stringify({ @@ -458,12 +459,32 @@ describe(" — paging", () => { meta: { total: items.length }, }, error: null, + // The route's cap on the envelope: a row past this page exists. + meta: { truncated, limit: 50 }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } - it("offers a next page after a full one and asks for the entries before it", async () => { + it("offers no next page after an exactly full one the route says is complete", async () => { + const base = Date.parse("2026-05-20T12:00:00.000Z"); + const fullPage = Array.from({ length: 50 }, (_, i) => ({ + id: `se-full-${i}`, + occurredAt: new Date(base - i * 60_000).toISOString(), + })); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce(sideEffectsResponse(fullPage, false)), + ); + const client = makeClient(); + render(, client); + await executeSideEffectsQuery(client, "med-full"); + await waitForQueryStatus(client, "med-full", "success"); + const html = render(, client); + expect(html).not.toContain(en.medications.sideEffects.loadMore); + }); + + it("offers a next page when the route says one exists, and asks for the entries before it", async () => { const base = Date.parse("2026-05-20T12:00:00.000Z"); const firstPage = Array.from({ length: 50 }, (_, i) => ({ id: `se-${i}`, @@ -472,7 +493,7 @@ describe(" — paging", () => { const oldest = firstPage[firstPage.length - 1]; const fetchMock = vi .fn() - .mockResolvedValueOnce(sideEffectsResponse(firstPage)) + .mockResolvedValueOnce(sideEffectsResponse(firstPage, true)) // The boundary entry comes back once more and is shown once. .mockResolvedValueOnce( sideEffectsResponse([ diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 114c1b23b..b28beff76 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -48,7 +48,7 @@ import { severityLikertLabel, } from "@/lib/medications/side-effects/taxonomy"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; -import { apiDelete, apiFetchEnvelope, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; /** @@ -168,15 +168,19 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const from = pageParam ? new Date(pageParam.from) : new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); - const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`; - const { data: payload } = await apiFetchEnvelope<{ + const range = `from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`; + const { data: payload, cap } = await apiGetList<{ items: SideEffectRow[]; meta: { total: number }; - }>(url); - return { ...payload, from: from.toISOString() }; + }>( + `/api/medications/${medicationId}/side-effects?${range}&limit=${SIDE_EFFECTS_PAGE_SIZE}`, + ); + return { ...payload, cap, from: from.toISOString() }; }, getNextPageParam: (lastPage): SideEffectPageParam | undefined => { - if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined; + // The route says whether a row past the page exists; a page that is + // exactly full is not a cut one. + if (!lastPage.cap?.truncated) return undefined; const oldest = lastPage.items[lastPage.items.length - 1]; return { from: lastPage.from, diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index b2c0565e3..c001dce23 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -43,7 +43,8 @@ function withProviders(node: React.ReactNode): string { // settled empty result. The failed and in-flight states are covered in // `failed-read-never-empty.test.tsx`. queryClient.setQueryData(queryKeys.mentalHealthAssessments(), { - assessments: [], + data: { assessments: [] }, + cap: null, }); return renderToStaticMarkup( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index c47885781..3919ed1e6 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -31,7 +31,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; -import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiGetList, apiPost } from "@/lib/api/api-fetch"; import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments"; import { PageHeader } from "@/components/ui/page-header"; @@ -49,6 +49,7 @@ import type { InstrumentId, Phase, } from "./types"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; export function MentalWellbeing() { const { t, locale } = useTranslations(); @@ -72,7 +73,7 @@ export function MentalWellbeing() { // assessments was told the assessments do not exist. Error is now rendered as // error, loading as loading, and only a genuinely empty result as empty. const { - data: history, + data: historyPage, isPending: historyPending, isError: historyError, error: historyFailure, @@ -80,10 +81,11 @@ export function MentalWellbeing() { } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), queryFn: () => - apiGet<{ assessments: AssessmentRow[] }>( + apiGetList<{ assessments: AssessmentRow[] }>( "/api/mental-health/assessments", ), }); + const history = historyPage?.data; const mutation = useMutation({ mutationFn: (body: unknown) => @@ -230,6 +232,7 @@ export function MentalWellbeing() { onStart={() => begin(detailInstrument)} /> )} + )} diff --git a/src/components/records/__tests__/allergy-manager.test.tsx b/src/components/records/__tests__/allergy-manager.test.tsx index dd2b60a9b..01e7c2c2a 100644 --- a/src/components/records/__tests__/allergy-manager.test.tsx +++ b/src/components/records/__tests__/allergy-manager.test.tsx @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ - data: mocks.rows, + data: { data: mocks.rows, cap: null }, isLoading: false, isError: false, refetch: vi.fn(), @@ -18,6 +18,7 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("@/lib/api/api-fetch", () => ({ apiDelete: vi.fn(), apiGet: vi.fn(), + apiGetList: vi.fn(), })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("@/components/ui/responsive-sheet", () => ({ diff --git a/src/components/records/__tests__/family-history-manager.test.tsx b/src/components/records/__tests__/family-history-manager.test.tsx index 1064b5607..8158c95ec 100644 --- a/src/components/records/__tests__/family-history-manager.test.tsx +++ b/src/components/records/__tests__/family-history-manager.test.tsx @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ - data: mocks.rows, + data: { data: mocks.rows, cap: null }, isLoading: false, isError: false, refetch: vi.fn(), @@ -18,6 +18,7 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("@/lib/api/api-fetch", () => ({ apiDelete: vi.fn(), apiGet: vi.fn(), + apiGetList: vi.fn(), })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("@/components/ui/responsive-sheet", () => ({ diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index 901c79731..c6a1ed919 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -14,12 +14,13 @@ import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGetList } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { AllergyForm } from "./allergy-form"; import type { AllergyDTO } from "@/lib/records/dto"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; /** * v1.25 — the structured allergy/intolerance manager. Renders in @@ -43,9 +44,15 @@ export function AllergyManager({ const [editing, setEditing] = useState(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, error, refetch } = useQuery({ + const { + data: page, + isLoading, + isError, + error, + refetch, + } = useQuery({ queryKey: queryKeys.allergyList(true), - queryFn: () => apiGet("/api/allergies"), + queryFn: () => apiGetList("/api/allergies"), }); const deleteMutation = useMutation({ @@ -74,7 +81,7 @@ export function AllergyManager({ queryClient.invalidateQueries({ queryKey: queryKeys.allergies() }); } - const rows = data ?? []; + const rows = page?.data ?? []; function renderRow(row: AllergyDTO) { const detail = [ @@ -168,7 +175,10 @@ export function AllergyManager({ } /> ) : ( -
    {rows.map(renderRow)}
+ <> +
    {rows.map(renderRow)}
+ + )} {/* The add action closes the list (design standards §12); an empty diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index ac2db6312..2f4aa0c08 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -13,12 +13,13 @@ import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGetList } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { FamilyHistoryForm } from "./family-history-form"; import type { FamilyHistoryEntryDTO } from "@/lib/records/dto"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; /** * v1.25 — the structured family-history manager. Renders in the @@ -33,9 +34,15 @@ export function FamilyHistoryManager() { const [editing, setEditing] = useState(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, error, refetch } = useQuery({ + const { + data: page, + isLoading, + isError, + error, + refetch, + } = useQuery({ queryKey: queryKeys.familyHistoryList(), - queryFn: () => apiGet("/api/family-history"), + queryFn: () => apiGetList("/api/family-history"), }); const deleteMutation = useMutation({ @@ -64,7 +71,7 @@ export function FamilyHistoryManager() { queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() }); } - const rows = data ?? []; + const rows = page?.data ?? []; function renderRow(row: FamilyHistoryEntryDTO) { const detail = [ @@ -130,7 +137,10 @@ export function FamilyHistoryManager() { } /> ) : ( -
    {rows.map(renderRow)}
+ <> +
    {rows.map(renderRow)}
+ + )} {/* The add action closes the list as the card's action row (design diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx index 288a064d4..3104f1fe5 100644 --- a/src/components/settings/__tests__/privacy-section.test.tsx +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -32,7 +32,10 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { // Seed the embedded session + trusted-device + activity reads so the // assembled cards paint without firing network. client.setQueryData(queryKeys.sessions(), { sessions: [] }); - client.setQueryData(queryKeys.securityActivity(), { events: [] }); + client.setQueryData(queryKeys.securityActivity(), { + data: { events: [] }, + cap: null, + }); // Seed a trusted device so the card paints (it hides itself when empty). client.setQueryData(queryKeys.trustedDevices(), { devices: [ @@ -80,7 +83,10 @@ describe(" — assembled Data & Privacy dashboard", () => { }, }); client.setQueryData(queryKeys.sessions(), { sessions: [] }); - client.setQueryData(queryKeys.securityActivity(), { events: [] }); + client.setQueryData(queryKeys.securityActivity(), { + data: { events: [] }, + cap: null, + }); client.setQueryData(queryKeys.trustedDevices(), { devices: [] }); const html = renderToStaticMarkup( diff --git a/src/components/settings/access/__tests__/access-panel.test.tsx b/src/components/settings/access/__tests__/access-panel.test.tsx index fe87aa436..aa6262296 100644 --- a/src/components/settings/access/__tests__/access-panel.test.tsx +++ b/src/components/settings/access/__tests__/access-panel.test.tsx @@ -32,7 +32,9 @@ function grantList(partial: Partial = {}): GrantList { return { given: [], received: [], retentionDays: 365, ...partial }; } -const activityRef: { value: RecordActivity | undefined } = { value: undefined }; +const activityRef: { + value: (RecordActivity & { cap: ListCap | null }) | undefined; +} = { value: undefined }; vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { const actual = @@ -64,6 +66,7 @@ vi.mock("@/hooks/use-account-switch", () => ({ import { GrantsGivenCard } from "../grants-given-card"; import { GrantsReceivedCard } from "../grants-received-card"; import { RecordActivityCard } from "../record-activity-card"; +import type { ListCap } from "@/lib/api/list-cap-shape"; function render(node: React.ReactNode): string { return renderToStaticMarkup( @@ -205,7 +208,7 @@ describe("when my record was opened", () => { // see would imply a completeness it does not have: somebody checking last // spring would read an empty list as "no" when the honest answer is // "further back than this reaches". - activityRef.value = { entries: [], retentionDays: 90, truncated: false }; + activityRef.value = { entries: [], retentionDays: 90, cap: null }; const html = render(); expect(html).toContain('data-slot="record-activity-retention"'); expect(html).toContain("last 90 days"); @@ -215,22 +218,26 @@ describe("when my record was opened", () => { // The second completeness claim. The window bounds how far back; the row // cap bounds how many, and a list that stopped at its ceiling in silence // let the oldest visible line read as the beginning. - activityRef.value = { entries: [], retentionDays: 365, truncated: true }; + activityRef.value = { + entries: [], + retentionDays: 365, + cap: { truncated: true, limit: 100 }, + }; const capped = render(); - expect(capped).toContain('data-slot="record-activity-truncated"'); - expect(capped).toContain("most recent activity"); + expect(capped).toContain('data-slot="list-cap-notice"'); + expect(capped).toContain("Showing the latest 100"); // And it is not an unconditional hedge: a caveat that is always on is one // people learn to skip, which would cost the sentence its meaning on the // one screen where it is true. - activityRef.value = { entries: [], retentionDays: 365, truncated: false }; + activityRef.value = { entries: [], retentionDays: 365, cap: null }; const whole = render(); - expect(whole).not.toContain('data-slot="record-activity-truncated"'); + expect(whole).not.toContain('data-slot="list-cap-notice"'); expect(whole).toContain('data-slot="record-activity-retention"'); }); it("prints the operator's window, never a compiled-in 365", () => { - activityRef.value = { entries: [], retentionDays: 30, truncated: false }; + activityRef.value = { entries: [], retentionDays: 30, cap: null }; const html = render(); expect(html).toContain("last 30 days"); expect(html).not.toContain("365"); @@ -247,7 +254,7 @@ describe("when my record was opened", () => { it("counts the openings rather than listing them one by one", () => { activityRef.value = { retentionDays: 365, - truncated: false, + cap: null, entries: [ { id: "a1", @@ -270,7 +277,7 @@ describe("when my record was opened", () => { // it themselves. activityRef.value = { retentionDays: 365, - truncated: false, + cap: null, entries: [ { id: "a2", @@ -285,3 +292,19 @@ describe("when my record was opened", () => { expect(html).toContain("A deleted account"); }); }); + +describe("grant lists at their ceiling", () => { + it("say so under the list, and only when the server cut it", () => { + grantsRef.value = { + ...grantList({ given: [BASE] }), + cap: { truncated: true, limit: 100 }, + } as GrantList; + expect(render()).toContain( + 'data-slot="list-cap-notice"', + ); + grantsRef.value = grantList({ given: [BASE] }); + expect(render()).not.toContain( + 'data-slot="list-cap-notice"', + ); + }); +}); diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index f7e6f5e49..71dc4d500 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -19,6 +19,7 @@ import { useRevokeGrant, type GrantRow, } from "@/lib/queries/use-account-grants"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; /** * v1.36.0 — who can open this record. @@ -87,6 +88,7 @@ export function GrantsGivenCard() { ))} )} + ); diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx index 9d75f9ba3..470e7a87c 100644 --- a/src/components/settings/access/grants-received-card.tsx +++ b/src/components/settings/access/grants-received-card.tsx @@ -23,6 +23,7 @@ import { useRenounceGrant, type GrantRow, } from "@/lib/queries/use-account-grants"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; /** * v1.36.0 — the records this account has been offered, and the ones it holds. @@ -110,6 +111,7 @@ export function GrantsReceivedCard() { ))} )} + ); diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx index 06fe54112..ae19c1b57 100644 --- a/src/components/settings/access/record-activity-card.tsx +++ b/src/components/settings/access/record-activity-card.tsx @@ -13,6 +13,7 @@ import { useRecordActivity, type RecordActivityEntry, } from "@/lib/queries/use-account-grants"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; /** * v1.36.0 — when this record was opened, and by whom. @@ -63,22 +64,12 @@ export function RecordActivityCard() { {t("recordSharing.activity.retention", { days: data.retentionDays, })} - {/* The second completeness claim. The window says how far back - this can see; the cap says how many rows came back, and a list - that stopped at its ceiling without saying so lets the oldest - line read as the beginning. Rendered only when the ceiling was - actually hit — an unconditional hedge would teach people to - ignore it. */} - {data.truncated && ( - <> - {" "} - - {t("recordSharing.activity.truncated")} - - - )}

)} + {/* The second completeness claim: the window says how far back this + can see, the cap how many rows came back. Rendered only when the + ceiling was actually hit. */} + {isLoading && ( - apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), + apiGetList<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), enabled: isAuthenticated, }); - const events = data?.events ?? []; + const events = page?.data.events ?? []; return ( @@ -152,6 +159,7 @@ export function SecurityActivityCard({ ))} )} + ); diff --git a/src/components/ui/__tests__/list-cap-notice.test.tsx b/src/components/ui/__tests__/list-cap-notice.test.tsx new file mode 100644 index 000000000..6fb300c0d --- /dev/null +++ b/src/components/ui/__tests__/list-cap-notice.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { readListCap } from "@/lib/api/list-cap-shape"; + +import { ListCapNotice } from "../list-cap-notice"; + +const render = (node: React.ReactNode) => + renderToStaticMarkup({node}); + +describe("ListCapNotice", () => { + it("says the list stopped at its ceiling, with the number", () => { + const html = render(); + expect(html).toContain('data-slot="list-cap-notice"'); + expect(html).toContain("Showing the latest 50"); + }); + + it("renders nothing for a complete list or no cap", () => { + expect( + render(), + ).toBe(""); + expect(render()).toBe(""); + }); +}); + +describe("readListCap", () => { + it("reads the envelope's cap and nothing else", () => { + expect( + readListCap({ truncated: true, limit: 100, requestId: "r" }), + ).toEqual({ truncated: true, limit: 100 }); + expect(readListCap({ aggregate: {} })).toBeNull(); + expect(readListCap({ truncated: "yes", limit: 1 })).toBeNull(); + expect(readListCap(undefined)).toBeNull(); + }); +}); diff --git a/src/components/ui/list-cap-notice.tsx b/src/components/ui/list-cap-notice.tsx new file mode 100644 index 000000000..c1fe6160b --- /dev/null +++ b/src/components/ui/list-cap-notice.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ListCap } from "@/lib/api/list-cap-shape"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * The line under a list the server cut at its ceiling. Renders nothing for a + * complete list, so a surface passes the cap it read (`apiGetList`) and + * never decides on its own whether to show it. Meta tier: one muted sentence + * at the `text-xs` floor. + */ +export function ListCapNotice({ + cap, + className, +}: { + cap: ListCap | null | undefined; + className?: string; +}) { + const { t } = useTranslations(); + if (!cap?.truncated) return null; + return ( +

+ {t("common.listCapped", { count: String(cap.limit) })} +

+ ); +} diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 95c13f927..ffabbc4a8 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -56,6 +56,7 @@ import { validateResponseContext, withRecordFenceHeaders, } from "./record-fence"; +import { readListCap, type ListCap } from "@/lib/api/list-cap-shape"; /** HTTP-level failure from an `/api/...` route — non-OK status. */ export class ApiError extends Error { @@ -214,6 +215,35 @@ function withJsonBody( return merged; } +/** + * GET a capped list — resolves with `data` and the envelope's list cap + * (`meta.truncated` / `meta.limit`), null when the route sent none. The one + * client reader of the cap; pair it with `ListCapNotice`. + */ +export async function apiGetList( + path: string, + init?: RequestInit, +): Promise<{ data: T; cap: ListCap | null }> { + const { data, meta } = await apiFetchEnvelope(path, { + ...init, + method: "GET", + }); + return { data, cap: readListCap(meta) }; +} + +/** POST variant of {@link apiGetList}, for a list read with a body. */ +export async function apiPostList( + path: string, + body?: unknown, + init?: RequestInit, +): Promise<{ data: T; cap: ListCap | null }> { + const { data, meta } = await apiFetchEnvelope( + path, + withDefaultIdempotencyKey(withJsonBody("POST", body, init)), + ); + return { data, cap: readListCap(meta) }; +} + /** GET — resolves with the unwrapped `data` payload. */ export function apiGet( path: string, diff --git a/src/lib/api/list-cap-shape.ts b/src/lib/api/list-cap-shape.ts new file mode 100644 index 000000000..f6004cf2d --- /dev/null +++ b/src/lib/api/list-cap-shape.ts @@ -0,0 +1,22 @@ +/** + * The wire shape of a list cap, safe for the client bundle. + * + * A capped list answers with `meta: { truncated, limit }` on the envelope + * (`apiListSuccess` in `./list-cap.ts`). That is the one place the signal + * rides; `readListCap` is the one place a client reads it. + */ +export interface ListCap { + /** A row past `limit` exists and was not returned. */ + truncated: boolean; + /** The most rows this response could carry. */ + limit: number; +} + +/** The cap an envelope's `meta` carries, or null when it carries none. */ +export function readListCap(meta: unknown): ListCap | null { + if (!meta || typeof meta !== "object") return null; + const { truncated, limit } = meta as Record; + if (typeof truncated !== "boolean") return null; + if (typeof limit !== "number" || !Number.isFinite(limit)) return null; + return { truncated, limit }; +} diff --git a/src/lib/api/list-cap.ts b/src/lib/api/list-cap.ts index e2bc9ef4f..98cd2436c 100644 --- a/src/lib/api/list-cap.ts +++ b/src/lib/api/list-cap.ts @@ -17,16 +17,16 @@ * `data` keeps exactly the shape the route always had and a client that never * reads `meta` decodes it unchanged. `truncated` is exact: true only when a * row past the limit exists. The OpenAPI side is `listEnvelope` in - * `src/lib/openapi/routes/shared.ts`. + * `src/lib/openapi/routes/shared.ts`. A client reads it through `apiGetList` + * / `apiPostList` (`src/lib/api/api-fetch.ts`) and shows it with + * `ListCapNotice`; `src/__tests__/list-cap-reader-guard.test.ts` binds every + * capped route to such a reader. */ import { apiSuccessWithMeta } from "@/lib/api-response"; -export interface ListCap { - /** A row past `limit` exists and was not returned. */ - truncated: boolean; - /** The most rows this response could carry. */ - limit: number; -} +import type { ListCap } from "./list-cap-shape"; + +export type { ListCap } from "./list-cap-shape"; /** The `take` that lets {@link capRows} tell a full list from a cut one. */ export function overfetch(limit: number): number { diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index c12a73a2d..54a0e12fc 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -354,11 +354,6 @@ const recordActivityResponse = z .describe( "How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows.", ), - truncated: z - .boolean() - .describe( - "True when the read reached its row ceiling, so `entries` is the most recent activity inside the window rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest row read as the beginning. False means the list is everything inside the window.", - ), }) .meta({ id: "RecordActivity", @@ -875,7 +870,7 @@ export const accountSharingPaths: NonNullable = { tags: ["Account sharing"], summary: "Who opened this record, and when", description: - "Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Not delegable: a grant is not an introduction to the other people in the household.", + "Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Whether the list stopped at its row ceiling is `meta.truncated` (it was a `truncated` field in `data` before v1.43.7). Not delegable: a grant is not an introduction to the other people in the household.", responses: { ...stdResponses, "200": { diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts index f2db0e1b9..549b7aade 100644 --- a/src/lib/queries/use-account-grants.ts +++ b/src/lib/queries/use-account-grants.ts @@ -24,9 +24,10 @@ import { type QueryClient, } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch"; import { grantDependentKeys, queryKeys } from "@/lib/query-keys"; import type { ShareDomain } from "@/lib/sharing/scope"; +import type { ListCap } from "@/lib/api/list-cap-shape"; /** The other party to a grant, as far as anyone is told about them. */ export interface GrantParty { @@ -93,21 +94,26 @@ export interface RecordActivity { * the list is everything that ever happened. */ retentionDays: number; - /** - * True when the read hit its row ceiling, so the list is the most recent - * activity inside the window rather than all of it. - * - * Two different completeness claims, and both have to be stated: the window - * bounds how far back, this bounds how many. A list that silently stopped at - * its own cap would let the oldest line read as the beginning. - */ - truncated: boolean; } +/** A capped list as its hook hands it out: the payload and the route's cap. */ +type WithCap = T & { + /** + * The route's list cap (`meta` on the envelope), null when it sent none. + * Shown with `ListCapNotice`: the retention window bounds how far back the + * feed sees, the cap how many rows came back, and a list that stopped at + * its ceiling in silence would let its oldest line read as the beginning. + */ + cap: ListCap | null; +}; + export function useAccountGrants(enabled = true) { return useQuery({ queryKey: queryKeys.accountGrants(), - queryFn: () => apiGet("/api/account/grants"), + queryFn: async (): Promise> => { + const { data, cap } = await apiGetList("/api/account/grants"); + return { ...data, cap }; + }, enabled, }); } @@ -115,7 +121,12 @@ export function useAccountGrants(enabled = true) { export function useRecordActivity(enabled = true) { return useQuery({ queryKey: queryKeys.accountActivity(), - queryFn: () => apiGet("/api/account/activity"), + queryFn: async (): Promise> => { + const { data, cap } = await apiGetList( + "/api/account/activity", + ); + return { ...data, cap }; + }, enabled, }); } diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 426b86d9c..f29121eb2 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -462,8 +462,8 @@ describe("the activity feed states its own limits", () => { data: { entries: { action: string }[]; retentionDays: number; - truncated: boolean; }; + meta: { truncated: boolean; limit: number }; }; } @@ -509,7 +509,9 @@ describe("the activity feed states its own limits", () => { } const small = await feed(); expect(small.data.entries).toHaveLength(3); - expect(small.data.truncated).toBe(false); + // The cap rides the envelope, like every capped list's. + expect(small.meta.truncated).toBe(false); + expect(small.data).not.toHaveProperty("truncated"); // Over it: same shape, different claim. await getPrismaClient().auditLog.createMany({ @@ -522,6 +524,6 @@ describe("the activity feed states its own limits", () => { }); const large = await feed(); expect(large.data.entries).toHaveLength(100); - expect(large.data.truncated).toBe(true); + expect(large.meta).toMatchObject({ truncated: true, limit: 100 }); }); });