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

@@ -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 ? (