mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}."
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user