Lighten the client shell and finish four client surfaces

- Load each settings section on its own: the section route imported all
  twenty-one statically. A preload starts the chunk beside the record
  gate's read, and the loading state paints the settings card shell.
  /settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
  run on each boot is plain TypeScript now, held to the published schema
  by an equivalence test over good and broken blocks. The canonical unit
  table moves to its own module, so the display transform no longer
  pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
  304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
  instead of the smooth scroll that lost the end, then follow late
  layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
  across on a phone and three or four on a desktop, with the month
  headings kept.
- Add the admin card for the measurement table maintenance: a status
  read on the maintenance route, one calm confirmation, and the run's
  state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
  watched at 403, aggregate cap 4335 with the reason written down.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 11:25:11 +02:00
1 parent bb563b6506
commit 12e422c25e
38 files changed
+2505 -406

No files matched your search

@@ -0,0 +1,247 @@
"use client";
/**
* `<MeasurementMaintenanceCard>` — start the measurement table maintenance
* and follow it, from the admin console instead of a `curl` against the
* route (runbook: `docs/ops/measurement-maintenance.md`).
*
* Calm on purpose. The button asks once, says what the run does and that it
* can take a while, and then the card shows where the run stands: waiting,
* running since when, finished, or stopped. It polls only while a run is
* queued or running. It stays disabled while the compaction-tombstone purge
* is still at work, because a run started then would refuse at once.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Loader2, Wrench } from "lucide-react";
import { toast } from "sonner";
import { StatTile } from "@/components/admin/_stat-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
import { queryKeys } from "@/lib/query-keys";
import { getApiErrorMessage } from "./_shared";
const ENDPOINT = "/api/admin/maintenance/measurements";
/** How often the card asks while a run is waiting or working. */
const POLL_MS = 10_000;
const GIB = 1024 * 1024 * 1024;
function sizeLabel(
bytes: number,
fmt: ReturnType<typeof useFormatters>,
): string {
// The shared formatter stops at MB; this table is routinely gigabytes.
return bytes >= GIB
? `${fmt.number(bytes / GIB, 2)} GB`
: formatBytes(bytes, fmt);
}
export function MeasurementMaintenanceCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = useQuery({
queryKey: queryKeys.adminMeasurementMaintenance(),
queryFn: () =>
apiGet<MeasurementMaintenanceStatus>(ENDPOINT, {
credentials: "include",
}),
refetchInterval: (query) => {
const state = query.state.data?.run?.state;
return state === "queued" || state === "running" ? POLL_MS : false;
},
});
const start = useMutation({
mutationFn: async (): Promise<{ enqueued: boolean }> => {
const res = await apiFetchRaw(ENDPOINT, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ vacuum: true, reindex: true }),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
return ((await res.json()) as { data: { enqueued: boolean } }).data;
},
onSuccess: ({ enqueued }) => {
if (enqueued) toast.success(t("admin.section.maintenance.started"));
else toast.info(t("admin.section.maintenance.alreadyQueued"));
void queryClient.invalidateQueries({
queryKey: queryKeys.adminMeasurementMaintenance(),
});
},
onError: (err: Error) => {
toast.error(err.message || t("admin.section.maintenance.startFailed"));
},
});
const header = (
<SettingsCardHeader
icon={Database}
title={t("admin.section.maintenance.title")}
description={t("admin.section.maintenance.description")}
/>
);
if (status.isError) {
return (
<QueryErrorCard
title={t("admin.section.maintenance.loadError")}
onRetry={() => void status.refetch()}
/>
);
}
const s = status.data;
const run = s?.run ?? null;
const active = run?.state === "queued" || run?.state === "running";
const blocked = !s || !s.available || s.purgePending || active;
return (
<SettingsCard data-testid="admin-measurement-maintenance">
{header}
<p className="text-sm">{t("admin.section.maintenance.body")}</p>
{s?.sizes ? (
<div className="grid gap-3 sm:grid-cols-2">
<StatTile
label={t("admin.section.maintenance.tableSize")}
value={sizeLabel(s.sizes.tableBytes, fmt)}
/>
<StatTile
label={t("admin.section.maintenance.indexSize")}
value={sizeLabel(s.sizes.indexBytes, fmt)}
/>
</div>
) : null}
<div
className="text-sm"
role="status"
aria-live="polite"
data-slot="maintenance-state"
data-state={status.isLoading ? "loading" : (run?.state ?? "idle")}
>
{status.isLoading ? (
<span className="text-muted-foreground flex items-center gap-2">
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("admin.section.maintenance.loading")}
</span>
) : !s?.available ? (
<span className="text-muted-foreground">
{t("admin.section.maintenance.unavailable")}
</span>
) : run?.state === "queued" ? (
<span className="flex items-center gap-2">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("admin.section.maintenance.state.queued")}
</span>
) : run?.state === "running" ? (
<span className="flex items-center gap-2">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("admin.section.maintenance.state.running", {
since: fmt.time(run.startedAt ?? run.requestedAt),
})}
</span>
) : run?.state === "completed" ? (
<span>
{run.outcome === "refused_purge_running"
? t("admin.section.maintenance.state.refusedPurge")
: run.outcome === "already_running"
? t("admin.section.maintenance.state.alreadyRunning")
: t("admin.section.maintenance.state.completed", {
at: fmt.dateTime(run.finishedAt ?? run.requestedAt),
})}
</span>
) : run?.state === "failed" ? (
<span className="text-destructive">
{t("admin.section.maintenance.state.failed", {
at: fmt.dateTime(run.finishedAt ?? run.requestedAt),
})}
</span>
) : (
<span className="text-muted-foreground">
{t("admin.section.maintenance.state.idle")}
</span>
)}
</div>
{s?.available && s.purgePending ? (
<p className="text-muted-foreground text-xs">
{t("admin.section.maintenance.purgePending")}
</p>
) : null}
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
disabled={blocked || start.isPending}
data-testid="admin-maintenance-start"
>
{start.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Wrench className="size-3.5" aria-hidden />
)}
{t("admin.section.maintenance.start")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.maintenance.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.maintenance.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.maintenance.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => start.mutate()}>
{t("admin.section.maintenance.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -336,3 +336,40 @@ describe("<UploadStateCard>", () => {
expect(html).toContain("1 GB");
});
});
describe('<DocumentCard variant="tile"> (the archive grid)', () => {
it("puts a 4:3 preview above the title, falling back to the kind icon", () => {
const html = render(
<DocumentCard
variant="tile"
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-variant="tile"');
const preview = html.indexOf('data-slot="document-preview"');
expect(preview).toBeGreaterThan(-1);
expect(html).toMatch(/data-slot="document-preview"[^>]*aspect-\[4\/3\]/);
// Preview first, title after it.
expect(html.indexOf("MRT Knie")).toBeGreaterThan(preview);
// No thumbnail rendered yet: the kind icon fills the preview, no <img>.
expect(html).not.toContain("<img");
});
it("keeps the row card as the default for every other surface", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-variant="row"');
expect(html).not.toContain('data-slot="document-preview"');
});
});
@@ -0,0 +1,52 @@
/**
* The archive grid's shape: two tiles side by side on a phone, three or four
* on a desktop, measured from the grid's own width (the AuthShell inset is
* already taken off by the time the grid measures itself).
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
columnsForWidth,
DocumentMonthHeading,
estimatedRowHeight,
} from "../document-month-grid";
describe("columnsForWidth", () => {
it("lays a 390 px phone out in two columns, the narrowest screens in one", () => {
// 390 viewport minus the shell's 16 px gutters.
expect(columnsForWidth(358)).toBe(2);
expect(columnsForWidth(320 - 32)).toBe(1);
});
it("uses three columns on a small desktop and four on a wide one", () => {
expect(columnsForWidth(1024 - 48)).toBe(3);
// 1440 viewport: the shell caps the container at 1280 minus its gutters.
expect(columnsForWidth(1280 - 48)).toBe(4);
});
it("never asks for a fifth column", () => {
expect(columnsForWidth(4000)).toBe(4);
});
});
describe("estimatedRowHeight", () => {
it("grows with the tile's width, so the first guess is close to the measured row", () => {
expect(estimatedRowHeight(358, 2)).toBeLessThan(
estimatedRowHeight(1232, 2),
);
expect(estimatedRowHeight(358, 2)).toBeGreaterThan(200);
});
});
describe("<DocumentMonthHeading>", () => {
it("keeps the month heading as the grid's section label", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentMonthHeading label="March 2026" />
</I18nProvider>,
);
expect(html).toMatch(/^<h2 [^>]*uppercase[^>]*>March 2026<\/h2>$/);
});
});
+112 -67
View File
@@ -52,7 +52,14 @@ export function DocumentCard({
tabIndex = 0,
onCardFocus,
onPrefetch,
variant = "row",
}: {
/**
* `row` (default): the compact card with the preview at its leading edge.
* `tile`: the archive grid's tile, the preview filling the top of the card
* so a page is recognisable before its title is read.
*/
variant?: "row" | "tile";
document: InboundDocumentDto;
selected: boolean;
/** `range` = extend the selection from the last anchor (shift-click). */
@@ -127,9 +134,76 @@ export function DocumentCard({
wasProcessingRef.current = processing;
}, [processing]);
const tile = variant === "tile";
const showThumbnail = !!thumbnailUrl && !thumbnailFailed;
// An object URL over bytes the app transport already fetched, not a
// `/api/…` subresource: the browser issues a subresource itself and so
// cannot carry the record-session assertion, which the fence refuses on any
// session that has been inside a shared record. `next/image` is wrong here
// for the older reason too — it would proxy a PHI blob we deliberately
// never cache. Decorative (alt="") — the title beside it names the
// document. The laziness that `loading="lazy"` used to provide now lives in
// the hook's null path: a card with no thumbnail, or one whose thumbnail
// already failed, issues no request at all. onError falls back to the kind
// icon.
const thumbnail = showThumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumbnailUrl}
alt=""
decoding="async"
onError={() => setThumbFailed(true)}
className={cn("size-full object-cover", tile && "object-top")}
/>
) : null;
const selection = onToggleSelected ? (
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
// On a tile the box floats over the preview, which can be any
// colour; the card background behind it keeps it readable.
tile && "bg-background absolute top-4 right-4",
)}
/>
) : null;
const titleBlock = (
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
</p>
{showFilename ? (
// Filename on its OWN muted line so it never clips the date/size
// meta; the full name stays reachable on hover via `title`.
<p
data-slot="document-card-filename"
className="text-muted-foreground truncate text-xs"
title={document.filename ?? undefined}
>
{document.filename}
</p>
) : null}
</div>
);
return (
<Card
data-slot="document-card"
data-variant={variant}
data-document-id={document.id}
className={cn(
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
@@ -137,76 +211,47 @@ export function DocumentCard({
highlighted && "ring-primary ring-2",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
{thumbnailUrl && !thumbnailFailed ? (
// Leading-edge preview tile. Decorative (alt="") — the title
// beside it names the document. The laziness that `loading="lazy"`
// used to provide now lives in the hook's null path: a card with no
// thumbnail, or one whose thumbnail already failed, issues no
// request at all. onError falls back to the kind icon.
<CardContent
className={cn(
"flex h-full flex-col gap-2",
// A dense tile (UI standards §1): two or more of them sit side by
// side on a phone, where the default inset would eat the preview.
tile ? "px-3" : "px-4",
)}
>
{tile ? (
<>
<span
data-slot="document-thumbnail"
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
data-slot="document-preview"
className="bg-muted flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-lg"
>
{/* An object URL over bytes the app transport already fetched,
not a `/api/…` subresource: the browser issues a subresource
itself and so cannot carry the record-session assertion, which
the fence refuses on any session that has been inside a shared
record. `next/image` is wrong here for the older reason too —
it would proxy a PHI blob we deliberately never cache. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbnailUrl}
alt=""
decoding="async"
onError={() => setThumbFailed(true)}
className="size-full object-cover"
/>
</span>
) : (
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
</p>
{showFilename ? (
// Filename on its OWN muted line so it never clips the date/size
// meta; the full name stays reachable on hover via `title`.
<p
data-slot="document-card-filename"
className="text-muted-foreground truncate text-xs"
title={document.filename ?? undefined}
>
{document.filename}
</p>
) : null}
</div>
{onToggleSelected && (
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
{thumbnail ?? (
<Icon className="text-foreground size-8" aria-hidden />
)}
/>
)}
</div>
</span>
{titleBlock}
{selection}
</>
) : (
<div className="flex items-start gap-2">
{thumbnail ? (
// Leading-edge preview tile.
<span
data-slot="document-thumbnail"
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
>
{thumbnail}
</span>
) : (
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
)}
{titleBlock}
{selection}
</div>
)}
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ||
processing ||
@@ -0,0 +1,92 @@
"use client";
/**
* The archive's month grid: a month heading, then that month's documents as
* preview tiles, so a page is recognisable by its look before its title is
* read. The timeline owns windowing and keyboard focus; this module owns how
* one windowed item is laid out and how many tiles share a row.
*
* Two tiles side by side on a phone (one on the narrowest screens), three or
* four on a desktop. The count comes from the grid's measured width, not from
* viewport breakpoints, so the virtualizer's row model and the painted grid
* can never disagree.
*/
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard } from "./document-card";
/** Measured grid width → tiles per row. */
export function columnsForWidth(width: number): number {
if (width >= 1100) return 4;
if (width >= 760) return 3;
if (width >= 330) return 2;
return 1;
}
/** Gap between tiles and between rows, in px (`gap-4` / `pb-4`). */
const GRID_GAP_PX = 16;
/**
* First guess at a row's height before the virtualizer measures it: a 4:3
* preview across the tile's width plus the title and meta lines under it.
*/
export function estimatedRowHeight(width: number, columns: number): number {
const cols = Math.max(1, columns);
const tileWidth = Math.max(0, (width - GRID_GAP_PX * (cols - 1)) / cols);
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX;
}
export function DocumentMonthHeading({ label }: { label: string }) {
return (
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
{label}
</h2>
);
}
export function DocumentMonthRow({
documents,
columns,
selectedIds,
onToggleSelected,
onOpen,
onDelete,
highlightId,
rovingId,
onCardFocus,
onPrefetch,
}: {
documents: InboundDocumentDto[];
columns: number;
selectedIds: ReadonlySet<string>;
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
onDelete?: (id: string) => void;
highlightId: string | null;
rovingId: string | null;
onCardFocus: (id: string) => void;
onPrefetch?: (id: string) => void;
}) {
return (
<div
data-slot="document-month-row"
className="grid gap-4 pb-4"
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{documents.map((doc) => (
<DocumentCard
key={doc.id}
variant="tile"
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={onCardFocus}
onPrefetch={onPrefetch}
/>
))}
</div>
);
}
+36 -38
View File
@@ -1,7 +1,8 @@
"use client";
/**
* The vault timeline: a virtualized, month-sectioned card grid windowed
* The vault timeline: a virtualized, month-sectioned grid of preview tiles
* (`document-month-grid.tsx`) windowed
* with `@tanstack/react-virtual` over the shell's scroll container
* (`#main-content` — single scroll owner per the design standards; the
* timeline never brings its own scrollport). The flat item list (month
@@ -9,8 +10,9 @@
* mounted DOM stays bounded (< ~400 nodes) regardless of corpus size.
*
* Columns are measured, not breakpoint-classed: a ResizeObserver on the
* grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps
* the virtualizer's row model and the painted grid in lockstep.
* grid container drives the per-row chunking (4 / 3 on a desktop, 2 on a
* phone, 1 on the narrowest screens), which keeps the virtualizer's row model
* and the painted grid in lockstep.
*
* In-flight / failed uploads render as a small non-virtualized grid above
* the timeline — they are few (client concurrency 3) and must appear
@@ -28,20 +30,18 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard, UploadStateCard } from "./document-card";
import { UploadStateCard } from "./document-card";
import {
columnsForWidth,
DocumentMonthHeading,
DocumentMonthRow,
estimatedRowHeight,
} from "./document-month-grid";
import type { UploadQueueItem } from "./use-document-upload";
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
const SCROLL_CONTAINER_ID = "main-content";
/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */
function columnsForWidth(width: number): number {
if (width >= 1200) return 4;
if (width >= 900) return 3;
if (width >= 600) return 2;
return 1;
}
export function DocumentTimeline({
documents,
uploadItems,
@@ -77,6 +77,7 @@ export function DocumentTimeline({
const { t, locale } = useTranslations();
const listRef = useRef<HTMLDivElement | null>(null);
const [columns, setColumns] = useState(1);
const [gridWidth, setGridWidth] = useState(0);
const [scrollMargin, setScrollMargin] = useState(0);
// Roving tabindex over the card grid: exactly one card is tabbable; the
@@ -88,7 +89,10 @@ export function DocumentTimeline({
useEffect(() => {
const el = listRef.current;
if (!el) return;
const update = () => setColumns(columnsForWidth(el.clientWidth));
const update = () => {
setColumns(columnsForWidth(el.clientWidth));
setGridWidth(el.clientWidth);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
@@ -127,7 +131,10 @@ export function DocumentTimeline({
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID),
estimateSize: (index) => (items[index].type === "month" ? 40 : 140),
estimateSize: (index) =>
items[index].type === "month"
? 40
: estimatedRowHeight(gridWidth, columns),
getItemKey: (index) => items[index].key,
overscan: 6,
scrollMargin,
@@ -282,31 +289,22 @@ export function DocumentTimeline({
}}
>
{item.type === "month" ? (
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
{formatMonthLabel(item.key, locale)}
</h2>
<DocumentMonthHeading
label={formatMonthLabel(item.key, locale)}
/>
) : (
<div
className="grid gap-4 pb-4"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{item.documents.map((doc) => (
<DocumentCard
key={doc.id}
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={setActiveId}
onPrefetch={onPrefetch}
/>
))}
</div>
<DocumentMonthRow
documents={item.documents}
columns={columns}
selectedIds={selectedIds}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlightId={highlightId}
rovingId={rovingId}
onCardFocus={setActiveId}
onPrefetch={onPrefetch}
/>
)}
</div>
);
+10 -3
View File
@@ -993,10 +993,17 @@ export function DocumentsView() {
{list.isPending ? (
<div
data-slot="documents-loading"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
// The archive's tile shape (a 4:3 preview over two text lines), in
// the timeline's column counts, so the grid does not jump when the
// first page lands.
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: 9 }, (_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
) : list.isError ? (
@@ -0,0 +1,144 @@
/**
* A saved conversation opens at its last answer and stays there while late
* layout (charts, tables) grows the thread, until the reader takes over.
* Driven against a fake scroller: the test environment has no layout, and
* the behaviour under test is which scroll the thread asks for, and when.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_AT_END_SETTLE_MS, openAtEnd } from "../open-at-end";
type Listener = () => void;
function fakeScroller(children = 3) {
const listeners = new Map<string, Set<Listener>>();
const el = {
scrollHeight: 1000,
children: Array.from({ length: children }, () => ({}) as Element),
scrollTo: vi.fn(),
addEventListener: (type: string, fn: Listener) => {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type)!.add(fn);
},
removeEventListener: (type: string, fn: Listener) => {
listeners.get(type)?.delete(fn);
},
fire: (type: string) => {
for (const fn of [...(listeners.get(type) ?? [])]) fn();
},
listenerCount: () =>
[...listeners.values()].reduce((n, set) => n + set.size, 0),
};
return el;
}
/** A ResizeObserver whose callback the test fires by hand. */
function fakeResizeObserver() {
const instances: Array<{ fire: () => void; observed: unknown[] }> = [];
class FakeResizeObserver {
observed: unknown[] = [];
disconnected = false;
constructor(private readonly cb: () => void) {
instances.push(this as never);
}
observe(node: unknown) {
this.observed.push(node);
}
disconnect() {
this.disconnected = true;
}
fire() {
if (!this.disconnected) this.cb();
}
}
return {
Ctor: FakeResizeObserver as unknown as typeof ResizeObserver,
instances,
};
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("openAtEnd", () => {
it("jumps to the end at once, without the smooth animation that lost it", () => {
const el = fakeScroller();
openAtEnd(el, { ResizeObserver: undefined, MutationObserver: undefined });
expect(el.scrollTo).toHaveBeenCalledTimes(1);
expect(el.scrollTo).toHaveBeenCalledWith({
top: 1000,
behavior: "instant",
});
});
it("follows the end when a chart that loads later grows the thread", () => {
const el = fakeScroller();
const ro = fakeResizeObserver();
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
expect(ro.instances[0].observed).toHaveLength(3);
el.scrollHeight = 1070; // one chart's height, the phone shortfall
ro.instances[0].fire();
expect(el.scrollTo).toHaveBeenLastCalledWith({
top: 1070,
behavior: "instant",
});
});
it("lets go the moment the reader scrolls, touches or presses a key", () => {
for (const event of ["wheel", "touchstart", "keydown", "pointerdown"]) {
const el = fakeScroller();
const ro = fakeResizeObserver();
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
el.fire(event);
el.scrollHeight = 2000;
ro.instances[0].fire();
expect(el.scrollTo, event).toHaveBeenCalledTimes(1);
expect(el.listenerCount(), event).toBe(0);
}
});
it("stops following on its own once the layout has had time to settle", () => {
const el = fakeScroller();
const ro = fakeResizeObserver();
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
vi.advanceTimersByTime(OPEN_AT_END_SETTLE_MS);
el.scrollHeight = 3000;
ro.instances[0].fire();
expect(el.scrollTo).toHaveBeenCalledTimes(1);
expect(el.listenerCount()).toBe(0);
});
it("stops when the caller says so, as on switching conversations", () => {
const el = fakeScroller();
const ro = fakeResizeObserver();
const stop = openAtEnd(el, {
ResizeObserver: ro.Ctor,
MutationObserver: undefined,
});
stop();
ro.instances[0].fire();
expect(el.scrollTo).toHaveBeenCalledTimes(1);
});
});
describe("the thread wires it to the conversation it opens", () => {
it("runs on a layout effect keyed on the conversation", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const source = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/message-thread.tsx",
),
"utf8",
);
expect(source).toMatch(
/useLayoutEffect\(\(\) => \{[\s\S]*?return openAtEnd\(el\);[\s\S]*?\}, \[conversationId, hasMessages\]\);/,
);
});
});
@@ -4,6 +4,7 @@ import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -18,6 +19,7 @@ import { ChatBubble, type FollowUpOffer, type ReplyOffer } from "./chat-bubble";
import type { SuggestedReply } from "./suggested-replies";
import { CoachMessageDatesProvider } from "./coach-results";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { openAtEnd } from "./open-at-end";
import { focusCoachComposer } from "./composer-focus";
import {
EMPTY_LIVE_TURN_KEYS,
@@ -579,6 +581,21 @@ export function MessageThread({
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
}, []);
// Opening a saved conversation lands on its last answer, and stays there
// while the answers' charts and tables finish loading (see `open-at-end`).
// Keyed on the conversation, so switching to another one re-opens at its
// end even when both have the same number of messages; a layout effect, so
// the first painted frame is already the end rather than the top.
const conversationId = conversation?.id ?? null;
const hasMessages = messages.length > 0;
useLayoutEffect(() => {
const el = scrollerRef.current;
if (!el || !conversationId || !hasMessages) return;
wasPinnedRef.current = true;
setPinned(true);
return openAtEnd(el);
}, [conversationId, hasMessages]);
// Auto-scroll on new messages OR streaming-content growth, but only
// when the user was already at the bottom. v1.4.25 W5 — the
// optimistic user bubble counts as a new message; scroll on its
@@ -0,0 +1,102 @@
/**
* Open a saved conversation at its end, and keep it there while the answers
* finish laying out.
*
* A conversation opened from the history used to land at the top on a wide
* screen and short of the last answer on a phone. Two things worked against
* the thread's ordinary auto-scroll. It scrolled smoothly, and the scroll
* events of that animation passed through positions that were not at the
* bottom, so the thread concluded the reader had scrolled away and stopped
* following. And the answers' charts and tables load after the messages, so
* the end moved down after the scroll had already finished: about 70 px on a
* phone, one chart's height.
*
* So the opening jump is instant, and for a short while afterwards every
* growth of the thread's content moves the view to the new end. The settling
* stops early the moment the reader does anything that means "let me look
* around" (a wheel, a touch, a key, a pointer press inside the thread), and
* on its own after {@link OPEN_AT_END_SETTLE_MS}.
*/
/** How long late layout (charts, tables, images) may still move the end. */
export const OPEN_AT_END_SETTLE_MS = 4000;
/** Events that mean the reader has taken over the scroll position. */
const READER_EVENTS = [
"wheel",
"touchstart",
"keydown",
"pointerdown",
] as const;
/** The part of an element the settling needs; a real `HTMLElement` fits. */
export interface OpenAtEndScroller {
readonly scrollHeight: number;
readonly children: ArrayLike<Element>;
scrollTo(options: ScrollToOptions): void;
addEventListener(type: string, listener: () => void): void;
removeEventListener(type: string, listener: () => void): void;
}
/** The observers to use; the browser's by default, fakes in a test. */
interface Observers {
ResizeObserver?: typeof ResizeObserver;
MutationObserver?: typeof MutationObserver;
}
/**
* Jump to the end now and follow the end while the content settles. Returns
* the function that stops following; call it on unmount or when another
* conversation opens.
*/
export function openAtEnd(
el: OpenAtEndScroller,
observers: Observers = {
ResizeObserver: globalThis.ResizeObserver,
MutationObserver: globalThis.MutationObserver,
},
): () => void {
// `instant`, not `auto`: the thread carries `scroll-smooth`, and under it
// `auto` animates, which is exactly the motion that lost the end before.
const toEnd = () =>
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
toEnd();
let stopped = false;
const resize = observers.ResizeObserver
? new observers.ResizeObserver(() => {
if (!stopped) toEnd();
})
: null;
const watch = (node: Element) => resize?.observe(node);
for (const child of Array.from(el.children)) watch(child);
// A bubble that mounts after the opening (a lazily loaded answer part)
// is watched from then on, and its own arrival moves the end too.
const mutations = observers.MutationObserver
? new observers.MutationObserver((records) => {
if (stopped) return;
for (const record of records) {
for (const node of Array.from(record.addedNodes)) {
// An element (nodeType 1), not a text node: only elements resize.
if (node.nodeType === 1) watch(node as Element);
}
}
toEnd();
})
: null;
mutations?.observe(el as unknown as Node, { childList: true });
const stop = () => {
if (stopped) return;
stopped = true;
resize?.disconnect();
mutations?.disconnect();
clearTimeout(timer);
for (const type of READER_EVENTS) el.removeEventListener(type, stop);
};
for (const type of READER_EVENTS) el.addEventListener(type, stop);
const timer = setTimeout(stop, OPEN_AT_END_SETTLE_MS);
return stop;
}
@@ -0,0 +1,193 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, type ComponentType } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
/**
* One settings section, loaded on its own.
*
* The section route renders exactly one of twenty-one sections, but importing
* them statically put all twenty-one (and everything under them: the import
* panel, the integration cards, the MCP and API token tables, the score
* editor) into the route's entry bundle, so opening "About" downloaded the
* whole settings tree. Each section is now its own chunk. A first load still
* renders the section on the server (`ssr` stays on), so nothing jumps; the
* placeholder below only shows while a client-side switch between sections
* fetches the next chunk.
*/
/** Props a section takes. Only Integrations takes any. */
interface SectionProps {
callbackUrls?: IntegrationCallbackUrls;
}
function SectionLoading() {
// The settings card shell itself, so the section does not move when the
// real cards arrive (UI standards §13: a loading state paints the card).
return (
<div className="space-y-6" data-slot="settings-section-loading">
<SettingsCard data-slot="settings-section-loading-card">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-full max-w-md" />
<Skeleton className="h-9 w-full" />
</SettingsCard>
<SettingsCard data-slot="settings-section-loading-card">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-full max-w-sm" />
</SettingsCard>
</div>
);
}
/**
* One import per section, shared by the lazy component below and by the
* preload. One call site per file keeps the bundler to one chunk per section;
* a second `import()` of the same file elsewhere emitted a second copy.
*/
const LOADERS: Record<
SettingsSectionSlug,
() => Promise<ComponentType<SectionProps>>
> = {
account: () =>
import("@/components/settings/account-section").then(
(m) => m.AccountSection,
),
access: () =>
import("@/components/settings/access-section").then((m) => m.AccessSection),
security: () =>
import("@/components/settings/security-section").then(
(m) => m.SecuritySection,
),
modules: () =>
import("@/components/settings/modules-section").then(
(m) => m.ModulesSection,
),
about: () =>
import("@/components/settings/about-section").then((m) => m.AboutSection),
ai: () => import("@/components/settings/ai-section").then((m) => m.AiSection),
coach: () =>
import("@/components/settings/coach-section").then((m) => m.CoachSection),
integrations: () =>
import("@/components/settings/integrations-section").then(
(m) =>
function IntegrationsWithUrls({ callbackUrls }: SectionProps) {
// The route always passes the server-resolved URLs; the guard
// keeps the section's own prop contract required.
if (!callbackUrls) return null;
return <m.IntegrationsSection callbackUrls={callbackUrls} />;
},
),
sources: () =>
import("@/components/settings/sources-section").then(
(m) => m.SourcesSection,
),
notifications: () =>
import("@/components/settings/notifications-section").then(
(m) => m.NotificationsSection,
),
layout: () =>
import("@/components/settings/layout-section").then((m) => m.LayoutSection),
environment: () =>
import("@/components/settings/environment-section").then(
(m) => m.EnvironmentSection,
),
anamnesis: () =>
import("@/components/settings/anamnesis-section").then(
(m) => m.AnamnesisSection,
),
score: () =>
import("@/components/settings/score-section").then((m) => m.ScoreSection),
thresholds: () =>
import("@/components/settings/thresholds-section").then(
(m) => m.ThresholdsSection,
),
api: () =>
import("@/components/settings/api-section").then((m) => m.ApiSection),
mcp: () =>
import("@/components/settings/mcp-section").then((m) => m.McpSection),
gesundheitsakte: () =>
import("@/components/settings/gesundheitsakte-section").then(
(m) => m.GesundheitsakteSection,
),
export: () =>
import("@/components/settings/export-section").then((m) => m.ExportSection),
advanced: () =>
import("@/components/settings/advanced-section").then(
(m) => m.AdvancedSection,
),
privacy: () =>
import("@/components/settings/privacy-section").then(
(m) => m.PrivacySection,
),
};
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
account: dynamic(LOADERS.account, { loading: () => <SectionLoading /> }),
access: dynamic(LOADERS.access, { loading: () => <SectionLoading /> }),
security: dynamic(LOADERS.security, { loading: () => <SectionLoading /> }),
modules: dynamic(LOADERS.modules, { loading: () => <SectionLoading /> }),
about: dynamic(LOADERS.about, { loading: () => <SectionLoading /> }),
ai: dynamic(LOADERS.ai, { loading: () => <SectionLoading /> }),
coach: dynamic(LOADERS.coach, { loading: () => <SectionLoading /> }),
integrations: dynamic(LOADERS.integrations, {
loading: () => <SectionLoading />,
}),
sources: dynamic(LOADERS.sources, { loading: () => <SectionLoading /> }),
notifications: dynamic(LOADERS.notifications, {
loading: () => <SectionLoading />,
}),
layout: dynamic(LOADERS.layout, { loading: () => <SectionLoading /> }),
environment: dynamic(LOADERS.environment, {
loading: () => <SectionLoading />,
}),
anamnesis: dynamic(LOADERS.anamnesis, { loading: () => <SectionLoading /> }),
score: dynamic(LOADERS.score, { loading: () => <SectionLoading /> }),
thresholds: dynamic(LOADERS.thresholds, {
loading: () => <SectionLoading />,
}),
api: dynamic(LOADERS.api, { loading: () => <SectionLoading /> }),
mcp: dynamic(LOADERS.mcp, { loading: () => <SectionLoading /> }),
gesundheitsakte: dynamic(LOADERS.gesundheitsakte, {
loading: () => <SectionLoading />,
}),
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
};
/**
* The section mounts only once the record gate has the account
* (`RecordSettingsSectionGate`), so without a head start its chunk would be
* fetched after that and the reader would see two loading states in a row.
* This starts the fetch at hydration, beside the gate's own read; the module
* cache makes the later lazy render of the same file free.
*/
export function SettingsSectionPreload({
section,
}: {
section: SettingsSectionSlug;
}) {
useEffect(() => {
void LOADERS[section]().catch(() => {
// The section's own lazy import retries and reports; a failed head
// start changes nothing.
});
}, [section]);
return null;
}
export function SettingsSectionLoader({
section,
callbackUrls,
}: {
section: SettingsSectionSlug;
callbackUrls?: IntegrationCallbackUrls;
}) {
const Section = SECTIONS[section];
return <Section callbackUrls={callbackUrls} />;
}