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

+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>
);
}