mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
be79603d53
commit
dc5120b59a
49 files changed
+574
-204
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user