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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:04:11 +02:00
1 parent be79603d53
commit dc5120b59a
49 files changed
+574 -204

No files matched your search

+2 -7
View File
@@ -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:
+2 -2
View File
@@ -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}."
+2 -2
View File
@@ -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."
+2 -2
View File
@@ -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}."
+2 -2
View File
@@ -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."
+2 -2
View File
@@ -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."
+2 -2
View File
@@ -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} 님만 할 수 있어요."
+2 -2
View File
@@ -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}."
+2 -2
View File
@@ -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}."
+117
View File
@@ -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<string, string> = {
"/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<AllergyDTO[]>("/api/allergies")',
),
).toBe(true);
// A plain `apiGet` reads `data` only and does not count.
expect(
clientPathPattern("/api/allergies").test(
'apiGet<AllergyDTO[]>("/api/allergies")',
),
).toBe(false);
// A longer path that shares the prefix does not count either.
expect(
clientPathPattern("/api/allergies").test(
'apiGetList<AllergyDTO>("/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);
}
});
});
+3 -9
View File
@@ -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,
);
});
@@ -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(
<QueryClientProvider client={client}>
+3 -6
View File
@@ -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);
}
+1 -1
View File
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
+3 -8
View File
@@ -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 `<EcgSection>` reads (`insightsEcgList`) so the
// page and the section share one cache entry. The page only needs the
// `hasRecordings` flag to choose between the empty state and the section.
const { data, isLoading, 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
+7 -2
View File
@@ -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() {
<PredictionsPanel
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
history={history.data?.data}
/>
<CycleHistoryChart history={history.data} animate={play} />
<CycleHistoryChart
history={history.data?.data}
animate={play}
/>
<ListCapNotice cap={history.data?.cap} />
<BbtChart
days={calendar.data?.days ?? []}
today={today}
+3 -1
View File
@@ -17,6 +17,7 @@ import {
ApiError,
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
@@ -162,11 +163,12 @@ export function useCycleRingDial(enabled: boolean): {
};
}
/** The latest `limit` cycles, with the route's cap beside them. */
export function useCycleHistory(limit = 24) {
return useQuery({
queryKey: queryKeys.cycleHistory(limit),
queryFn: () =>
apiGet<CycleHistoryResponse>(`/api/cycle/cycles?limit=${limit}`),
apiGetList<CycleHistoryResponse>(`/api/cycle/cycles?limit=${limit}`),
staleTime: 60_000,
});
}
@@ -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;
});
});
+13 -4
View File
@@ -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<IllnessEpisodeDTO[]>(
const { data: page, cap } = await apiGetList<IllnessEpisodeDTO[]>(
`/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;
}
},
});
@@ -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(
<I18nProvider initialLocale="en">
<EcgCrossLink />
@@ -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(
<I18nProvider initialLocale="en">
<EcgSection {...props} />
@@ -174,4 +178,18 @@ describe("<EcgSection>", () => {
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"',
);
});
});
@@ -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(
<I18nProvider initialLocale="en">
<RhythmEventsCard />
+2 -28
View File
@@ -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<string, string> = {
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<EcgListResponse>("/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;
+7 -21
View File
@@ -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<EcgListResponse>("/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({
);
})}
</ol>
{/* The teaser keeps its newest few on purpose; the full list says
when the server stopped short of the history. */}
{limit == null && <ListCapNotice cap={page?.cap} />}
</InsightSectionCard>
</section>
);
@@ -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
+12 -3
View File
@@ -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<RhythmEventsResponse>(
return await apiGetList<RhythmEventsResponse>(
"/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({
);
})}
</ol>
<ListCapNotice cap={page?.cap} />
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
Plain React text children (no markdown library — XSS rule). */}
+37
View File
@@ -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<EcgListResponse>("/api/insights/ecg");
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
enabled: enabled && isAuthenticated,
});
}
@@ -444,6 +444,7 @@ describe("<SideEffectsSection> — timeline rows", () => {
describe("<SideEffectsSection> — paging", () => {
function sideEffectsResponse(
items: Array<{ id: string; occurredAt: string }>,
truncated = false,
): Response {
return new Response(
JSON.stringify({
@@ -458,12 +459,32 @@ describe("<SideEffectsSection> — 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(<SideEffectsSection medicationId="med-full" />, client);
await executeSideEffectsQuery(client, "med-full");
await waitForQueryStatus(client, "med-full", "success");
const html = render(<SideEffectsSection medicationId="med-full" />, 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("<SideEffectsSection> — 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([
@@ -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,
@@ -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(
<QueryClientProvider client={queryClient}>
@@ -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)}
/>
)}
<ListCapNotice cap={historyPage?.cap} className="mt-3" />
</ResponsiveSheet>
</>
)}
@@ -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", () => ({
@@ -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", () => ({
+15 -5
View File
@@ -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<AllergyDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, error, refetch } = useQuery({
const {
data: page,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.allergyList(true),
queryFn: () => apiGet<AllergyDTO[]>("/api/allergies"),
queryFn: () => apiGetList<AllergyDTO[]>("/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({
}
/>
) : (
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<>
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<ListCapNotice cap={page?.cap} />
</>
)}
{/* The add action closes the list (design standards §12); an empty
@@ -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<FamilyHistoryEntryDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, error, refetch } = useQuery({
const {
data: page,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.familyHistoryList(),
queryFn: () => apiGet<FamilyHistoryEntryDTO[]>("/api/family-history"),
queryFn: () => apiGetList<FamilyHistoryEntryDTO[]>("/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() {
}
/>
) : (
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<>
<ul className="space-y-2">{rows.map(renderRow)}</ul>
<ListCapNotice cap={page?.cap} />
</>
)}
{/* The add action closes the list as the card's action row (design
@@ -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("<PrivacySection> — 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(
<I18nProvider initialLocale="en">
@@ -32,7 +32,9 @@ function grantList(partial: Partial<GrantList> = {}): 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(<RecordActivityCard />);
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(<RecordActivityCard />);
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(<RecordActivityCard />);
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(<RecordActivityCard />);
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(<GrantsGivenCard />)).toContain(
'data-slot="list-cap-notice"',
);
grantsRef.value = grantList({ given: [BASE] });
expect(render(<GrantsGivenCard />)).not.toContain(
'data-slot="list-cap-notice"',
);
});
});
@@ -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() {
))}
</ul>
)}
<ListCapNotice cap={data?.cap} />
</div>
</SettingsCard>
);
@@ -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() {
))}
</ul>
)}
<ListCapNotice cap={data?.cap} />
</div>
</SettingsCard>
);
@@ -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 && (
<>
{" "}
<span data-slot="record-activity-truncated">
{t("recordSharing.activity.truncated")}
</span>
</>
)}
</p>
)}
{/* 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. */}
<ListCapNotice cap={data?.cap} />
{isLoading && (
<Loader2
@@ -18,8 +18,9 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { apiGetList } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
interface ActivityRow {
action: string;
@@ -68,14 +69,20 @@ export function SecurityActivityCard({
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError, error, refetch } = useQuery({
const {
data: page,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
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 (
<SettingsCard data-slot="settings-security-activity-card">
@@ -152,6 +159,7 @@ export function SecurityActivityCard({
))}
</ul>
)}
<ListCapNotice cap={page?.cap} />
</div>
</SettingsCard>
);
@@ -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(<I18nProvider initialLocale="en">{node}</I18nProvider>);
describe("ListCapNotice", () => {
it("says the list stopped at its ceiling, with the number", () => {
const html = render(<ListCapNotice cap={{ truncated: true, limit: 50 }} />);
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(<ListCapNotice cap={{ truncated: false, limit: 50 }} />),
).toBe("");
expect(render(<ListCapNotice cap={null} />)).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();
});
});
+30
View File
@@ -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 (
<p
data-slot="list-cap-notice"
className={cn("text-muted-foreground text-xs", className)}
>
{t("common.listCapped", { count: String(cap.limit) })}
</p>
);
}
+30
View File
@@ -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<T = unknown>(
path: string,
init?: RequestInit,
): Promise<{ data: T; cap: ListCap | null }> {
const { data, meta } = await apiFetchEnvelope<T>(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<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<{ data: T; cap: ListCap | null }> {
const { data, meta } = await apiFetchEnvelope<T>(
path,
withDefaultIdempotencyKey(withJsonBody("POST", body, init)),
);
return { data, cap: readListCap(meta) };
}
/** GET — resolves with the unwrapped `data` payload. */
export function apiGet<T = unknown>(
path: string,
+22
View File
@@ -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<string, unknown>;
if (typeof truncated !== "boolean") return null;
if (typeof limit !== "number" || !Number.isFinite(limit)) return null;
return { truncated, limit };
}
+7 -7
View File
@@ -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 {
+1 -6
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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": {
+23 -12
View File
@@ -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> = 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<GrantList>("/api/account/grants"),
queryFn: async (): Promise<WithCap<GrantList>> => {
const { data, cap } = await apiGetList<GrantList>("/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<RecordActivity>("/api/account/activity"),
queryFn: async (): Promise<WithCap<RecordActivity>> => {
const { data, cap } = await apiGetList<RecordActivity>(
"/api/account/activity",
);
return { ...data, cap };
},
enabled,
});
}
@@ -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 });
});
});