From dc5120b59a504a8f1871846ffe2d18520d9dede1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 10:04:11 +0200
Subject: [PATCH] feat(lists): read and show the list cap on every capped list
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
---
docs/api/openapi.yaml | 9 +-
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/ko.json | 4 +-
messages/nl.json | 4 +-
messages/pl.json | 4 +-
src/__tests__/list-cap-reader-guard.test.ts | 117 ++++++++++++++++++
src/app/api/account/activity/route.ts | 12 +-
.../__tests__/ecg-cross-link-mount.test.tsx | 7 +-
src/app/insights/catalog/page.tsx | 9 +-
src/app/insights/ecg/__tests__/page.test.tsx | 2 +-
src/app/insights/ecg/page.tsx | 11 +-
src/components/cycle/cycle-view.tsx | 9 +-
src/components/cycle/use-cycle.ts | 4 +-
.../use-illness-episodes-paging.test.ts | 28 ++++-
src/components/illness/use-illness.ts | 17 ++-
.../__tests__/ecg-cross-link.test.tsx | 4 +-
.../insights/__tests__/ecg-section.test.tsx | 20 ++-
.../__tests__/rhythm-events-card.test.tsx | 5 +-
src/components/insights/ecg-cross-link.tsx | 30 +----
src/components/insights/ecg-section.tsx | 28 ++---
.../insights/insights-layout-shell.tsx | 9 +-
.../insights/rhythm-events-card.tsx | 15 ++-
src/components/insights/use-ecg-list.ts | 37 ++++++
.../__tests__/side-effects-section.test.tsx | 25 +++-
.../medications/side-effects-section.tsx | 16 ++-
.../__tests__/mental-wellbeing.test.tsx | 3 +-
.../mental-health/mental-wellbeing.tsx | 9 +-
.../__tests__/allergy-manager.test.tsx | 3 +-
.../__tests__/family-history-manager.test.tsx | 3 +-
src/components/records/allergy-manager.tsx | 20 ++-
.../records/family-history-manager.tsx | 20 ++-
.../__tests__/privacy-section.test.tsx | 10 +-
.../access/__tests__/access-panel.test.tsx | 43 +++++--
.../settings/access/grants-given-card.tsx | 2 +
.../settings/access/grants-received-card.tsx | 2 +
.../settings/access/record-activity-card.tsx | 19 +--
.../settings/security-activity-card.tsx | 16 ++-
.../ui/__tests__/list-cap-notice.test.tsx | 36 ++++++
src/components/ui/list-cap-notice.tsx | 30 +++++
src/lib/api/api-fetch.ts | 30 +++++
src/lib/api/list-cap-shape.ts | 22 ++++
src/lib/api/list-cap.ts | 14 +--
src/lib/openapi/routes/account-sharing.ts | 7 +-
src/lib/queries/use-account-grants.ts | 35 ++++--
tests/integration/sharing-audit-actor.test.ts | 8 +-
49 files changed, 574 insertions(+), 204 deletions(-)
create mode 100644 src/__tests__/list-cap-reader-guard.test.ts
create mode 100644 src/components/insights/use-ecg-list.ts
create mode 100644 src/components/ui/__tests__/list-cap-notice.test.tsx
create mode 100644 src/components/ui/list-cap-notice.tsx
create mode 100644 src/lib/api/list-cap-shape.ts
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({
}
/>
) : (
-
+ <>
+
+
+ >
)}
{/* 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() {
}
/>
) : (
-
+ <>
+
+
+ >
)}
{/* 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 });
});
});