feat(documents): choose how the vault is laid out, and confirm every delete

The wrench beside Upload opens Settings, Appearance, Documents, the way
the illness journal offers its display options: preview cards or a
compact list, and months stacked as blocks or flowing across the full
width with the month name inline above the document that opens it. The
choice is stored per account in `users.documents_layout_json` behind
GET/PUT /api/documents/inbound/layout (preserve-when-absent, module
gated, the read delegable like the rest of the vault), carried in the
account-settings backup and cleared by the data wipe.

Deleting from the vault now always asks first. The bulk bar's Delete and
a focused card's Delete key both go through one confirmation on the page,
and the detail sheet's Delete opens its own; before, all three deleted on
the spot with only an undo toast behind them.

The selection bar is two tight lines: the count with an X at its right
edge, then one action row with Delete alone at the right in the
destructive style. Below 1024 px the verbs are icon-only at the 44 px tap
floor, so the row stays one line at 360 px. The bar carries the
selection-bar slot the Coach launcher already steps aside for.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:45:06 +02:00
1 parent ce6d2fe72a
commit 4273af231e
39 files changed
+1661 -209

No files matched your search

@@ -29,7 +29,7 @@ function render(
onLinkEpisode={() => {}}
onLinkEncounter={() => {}}
onShare={() => {}}
onDelete={() => {}}
onRequestDelete={() => {}}
onClear={() => {}}
/>
</I18nProvider>,
@@ -51,6 +51,51 @@ describe("<DocumentBulkBar>", () => {
expect(html).toContain("Clear selection");
});
it("clears with an X icon at the right of the count line, not a text button", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
const countLine = html.slice(
html.indexOf('data-slot="document-bulk-count"'),
html.indexOf('data-slot="document-bulk-actions"'),
);
// The clear control lives on the count line, before the action row…
expect(countLine).toContain('data-slot="document-bulk-clear"');
expect(countLine).toContain('aria-label="Clear selection"');
// …and carries no visible label: the name rides on aria-label and title.
expect(countLine).not.toContain(">Clear selection<");
});
it("keeps every verb in ONE action row with Delete alone at its right edge", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
const actions = html.slice(
html.indexOf('data-slot="document-bulk-actions"'),
);
const order = [
"document-bulk-set-kind",
"document-bulk-link-condition",
"document-bulk-link-visit",
"document-bulk-share",
"document-bulk-delete",
].map((slot) => actions.indexOf(`data-slot="${slot}"`));
expect(order.every((index) => index >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
// The row never wraps into a second line, and Delete is pushed right.
const rowTag = /<div[^>]*data-slot="document-bulk-actions"[^>]*>/.exec(
html,
);
expect(rowTag?.[0]).not.toContain("flex-wrap");
const deleteTag = /<button[^>]*data-slot="document-bulk-delete"[^>]*>/.exec(
html,
);
expect(deleteTag?.[0]).toContain("ml-auto");
});
it("carries the selection-bar slot the Coach launcher steps aside for", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
expect(html).toMatch(
/^<div data-slot="selection-action-bar" class="contents">/,
);
});
it("omits the link-condition verb when the account has no episodes", () => {
const html = render([]);
expect(html).not.toContain("Link condition");
@@ -7,12 +7,50 @@ import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
columnsForWidth,
DocumentMonthHeading,
DocumentMonthRow,
estimatedRowHeight,
listColumnsForWidth,
} from "../document-month-grid";
function doc(id: string): InboundDocumentDto {
return {
id,
kind: "DOCTOR_REPORT",
title: `Letter ${id}`,
filename: null,
mimeType: "application/pdf",
byteSize: 1024,
status: "STORED",
documentDate: "2026-03-10",
createdAt: "2026-03-10T10:00:00Z",
conditionLinks: [],
servingClass: "inline",
hasThumbnail: false,
hasContentIndex: true,
} as unknown as InboundDocumentDto;
}
function renderRow(props: Partial<Parameters<typeof DocumentMonthRow>[0]>) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentMonthRow
documents={[doc("a"), doc("b")]}
columns={2}
selectedIds={new Set()}
onOpen={() => {}}
highlightId={null}
rovingId="a"
onCardFocus={() => {}}
{...props}
/>
</I18nProvider>,
);
}
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.
@@ -50,3 +88,40 @@ describe("<DocumentMonthHeading>", () => {
expect(html).toMatch(/^<h2 [^>]*uppercase[^>]*>March 2026<\/h2>$/);
});
});
describe("listColumnsForWidth", () => {
it("keeps compact rows single-file on a phone and side by side on a desktop", () => {
expect(listColumnsForWidth(358)).toBe(1);
expect(listColumnsForWidth(1024 - 48)).toBe(2);
expect(listColumnsForWidth(1280 - 48)).toBe(3);
});
});
describe("<DocumentMonthRow>", () => {
it("renders preview tiles by default and compact rows in the list view", () => {
expect(renderRow({})).toContain('data-variant="tile"');
const list = renderRow({ view: "list" });
expect(list).toContain('data-variant="row"');
expect(list).not.toContain('data-variant="tile"');
});
it("flowing: the month name rides inline above the document that opens it", () => {
const html = renderRow({
monthStarts: { b: "2026-02" },
formatMonth: () => "February 2026",
});
expect(html.match(/data-slot="document-flow-month"/g)).toHaveLength(1);
expect(html).toContain("February 2026");
// The marker belongs to b's cell, not a's.
expect(html.indexOf("February 2026")).toBeGreaterThan(
html.indexOf('data-document-id="a"'),
);
expect(html.indexOf("February 2026")).toBeLessThan(
html.indexOf('data-document-id="b"'),
);
});
it("stacked rows carry no inline month marker", () => {
expect(renderRow({})).not.toContain('data-slot="document-flow-month"');
});
});
@@ -0,0 +1,55 @@
/**
* Every way of deleting from the vault asks first.
*
* Until v1.42 the bulk bar's Delete and a focused card's Delete key both
* called the bulk delete straight away; only the transient undo toast stood
* between a stray press and a medical document leaving the vault. The page now
* routes both through one confirmation (`requestDelete` → `ConfirmDialog` →
* `confirmDelete`), and the detail sheet's Delete opens its own.
*
* Structural rather than behavioural because the unit layer renders statically;
* the e2e journey (`documents-qol.spec.ts`) clicks through the real dialog. The
* matchers assert a non-zero count, so a rename that leaves them matching
* nothing fails instead of passing quietly.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const dir = join(process.cwd(), "src/components/documents");
const view = readFileSync(join(dir, "documents-view.tsx"), "utf8");
const sheet = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8");
const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8");
describe("vault delete paths confirm before deleting", () => {
it("the page calls the bulk delete only from the confirmation", () => {
const calls = [...view.matchAll(/deleteBulk\(\s*([^)]*)\)/g)].map((m) =>
m[1].trim(),
);
// One call site, and it is the confirmed id list.
expect(calls).toEqual(["pendingDeleteIds"]);
expect(view).toMatch(
/const confirmDelete = useCallback\(\(\) => \{\s*if \(pendingDeleteIds\) deleteBulk\(pendingDeleteIds\);/,
);
expect(view).toContain("<ConfirmDialog");
});
it("the bulk bar and the card key both go through requestDelete", () => {
expect(view).toMatch(
/onDelete=\{\s*canManageDocuments \? \(id\) => requestDelete\(\[id\]\)/,
);
expect(view).toMatch(
/onRequestDelete=\{\(\) => requestDelete\(\[\.\.\.selectedIds\]\)\}/,
);
// The bar has no delete of its own to call.
expect(bar).not.toMatch(/\bonDelete\b/);
expect(bar).toMatch(/onClick=\{onRequestDelete\}/);
});
it("the detail sheet's Delete opens a confirmation instead of deleting", () => {
const mutateCalls = [...sheet.matchAll(/remove\.mutate\(/g)];
expect(mutateCalls.length).toBe(1);
expect(sheet).toMatch(/onConfirm=\{\(\) => remove\.mutate\(doc\.id\)\}/);
expect(sheet).toMatch(/onClick=\{\(\) => setConfirmDeleteOpen\(true\)\}/);
});
});
@@ -49,11 +49,18 @@ import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DocumentsView } from "../documents-view";
function render(search: string, seedList?: { kinds?: never } | object) {
function render(
search: string,
seedList?: { kinds?: never } | object,
layout: object | null = { version: 1, view: "cards", arrangement: "stacked" },
) {
mockSearch = search;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (layout !== null) {
queryClient.setQueryData(queryKeys.documentsLayout(), layout);
}
if (seedList !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), {
pages: [{ documents: [], nextCursor: null }],
@@ -80,6 +87,29 @@ describe("<DocumentsView> states", () => {
expect(html).not.toContain("Your document vault is empty");
});
it("header: a wrench opens the vault's display settings, left of Upload", () => {
const html = render("", {});
const wrench = html.indexOf('data-slot="documents-customize"');
expect(wrench).toBeGreaterThan(-1);
expect(html).toContain('href="/settings/layout/documents"');
expect(html.indexOf("Upload", wrench)).toBeGreaterThan(wrench);
});
it("first load waits for the presentation, then paints the list skeleton for a list reader", () => {
// The presentation read is still out: the vault does not guess a shape.
const pending = render("", {}, null);
expect(pending).toContain('data-slot="documents-loading"');
expect(pending).not.toContain("Your document vault is empty");
const listReader = render("", undefined, {
version: 1,
view: "list",
arrangement: "flow",
});
expect(listReader).toContain('data-slot="documents-loading"');
expect(listReader).not.toContain("aspect-[4/3]");
});
it("filter bar: type facet is a compact dropdown, not an inline chip row", () => {
const html = render("");
// The type facet renders as a single dropdown trigger labelled by the
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildProcedureChoices,
buildFlowTimelineItems,
buildTimelineItems,
buildVaultListApiSearch,
classifyUploadFailure,
@@ -127,6 +128,28 @@ describe("API list search", () => {
});
describe("timeline items", () => {
it("flowing: rows run across month boundaries and mark where each month opens", () => {
const docs = [
doc({ id: "a", documentDate: "2026-03-20" }),
doc({ id: "b", documentDate: "2026-03-15" }),
doc({ id: "c", documentDate: "2026-02-28" }),
doc({ id: "d", documentDate: "2026-02-10" }),
doc({ id: "e", documentDate: "2026-01-05" }),
];
const items = buildFlowTimelineItems(docs, 3, "UTC");
// No row is spent on a month: five documents in three columns are two rows.
expect(items.map((i) => i.type)).toEqual(["row", "row"]);
expect(items[0]).toMatchObject({
documents: [docs[0], docs[1], docs[2]],
monthStarts: { a: "2026-03", c: "2026-02" },
});
// February continues into the second row without a second marker.
expect(items[1]).toMatchObject({
documents: [docs[3], docs[4]],
monthStarts: { e: "2026-01" },
});
});
it("buckets by month and chunks rows to the column count", () => {
const docs = [
doc({ id: "a", documentDate: "2026-03-20" }),
+176 -148
View File
@@ -3,20 +3,24 @@
/**
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* bulk verbs — set type, link condition, file against a visit, share (one
* link for the whole selection), delete (undo-able), clear — all driving the
* page's handlers.
* and near the bottom edge on desktop.
*
* Two tight lines. The first carries the selected count and, at its right
* edge, the X that clears the selection. The second is the one action row:
* set type, link condition, file against a visit and share lead from the
* left, and Delete sits alone at the right edge in the destructive style.
* Delete never deletes from here — it asks the page, which confirms first
* (`onRequestDelete`), the same path the card's Delete key takes.
*
* The two link verbs are the same shape and stay that way: each renders only
* when the account HAS something to link to, so an empty menu is never
* offered.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. Share sits just
* before Delete (the safe actions lead). The bar is a deliberate hand-rolled
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
* per the standards.
* Below `lg` every verb is icon-only with its name in `aria-label` and
* `title`, at the 44 px tap floor, so the row stays one line at 360 px; the
* labels return where the bar is wide enough to hold them on one line. A
* `role="toolbar"`; the bar is a deliberate hand-rolled shell (a floating
* toolbar is not a Card surface): dense-tile padding `p-3` per the standards.
*/
import {
CalendarClock,
@@ -50,6 +54,13 @@ export interface BulkEncounterOption {
label: string;
}
/** Shared shape of the leading verbs: icon-only below `lg`, labelled from it. */
const VERB_CLASS = "min-h-11 min-w-11 sm:min-h-9 sm:min-w-9";
function VerbLabel({ children }: { children: string }) {
return <span className="hidden lg:inline">{children}</span>;
}
export function DocumentBulkBar({
selectedCount,
episodes,
@@ -59,7 +70,7 @@ export function DocumentBulkBar({
onLinkEpisode,
onLinkEncounter,
onShare,
onDelete,
onRequestDelete,
onClear,
}: {
selectedCount: number;
@@ -78,153 +89,170 @@ export function DocumentBulkBar({
* this handler just opens the share sheet seeded with the selection.
*/
onShare: () => void;
onDelete: () => void;
/** Ask to delete the selection. The page confirms before anything goes. */
onRequestDelete: () => void;
onClear: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
{t("documents.selection.count", { count: selectedCount })}
</p>
// The outer slot is the data-list selection bar's: the Coach launcher
// watches for it and steps aside while a selection is open, so it never
// sits over Delete at the bar's right edge. `contents` keeps the wrapper
// out of the layout.
<div data-slot="selection-action-bar" className="contents">
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
"flex w-[calc(100%-2rem)] max-w-3xl flex-col gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<div className="flex items-center justify-between gap-2">
<p
className="px-1 text-sm font-medium whitespace-nowrap"
role="status"
data-slot="document-bulk-count"
>
{t("documents.selection.count", { count: selectedCount })}
</p>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 sm:size-9"
onClick={onClear}
data-slot="document-bulk-clear"
aria-label={t("documents.selection.clear")}
title={t("documents.selection.clear")}
>
<X className="size-4" aria-hidden />
</Button>
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
{/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a
2–3-row floating slab at ~360 px. Below `sm` every verb collapses
to icon-only (`aria-label` carries the accessible name; the
visible label returns at `sm+`) — the `document-detail-sheet`
footer's established icon-collapse pattern. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.setKind")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkCondition")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkVisit")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
<div
data-slot="document-bulk-actions"
className="flex items-center gap-1.5"
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">{t("documents.bulk.share")}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-set-kind"
aria-label={t("documents.bulk.setKind")}
title={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.setKind")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Solid destructive (matching the detail sheet's Delete) — the
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-link-condition"
aria-label={t("documents.bulk.linkCondition")}
title={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.linkCondition")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
className={VERB_CLASS}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
title={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.linkVisit")}</VerbLabel>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onShare}
className={VERB_CLASS}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
title={t("documents.bulk.share")}
>
<Share2 className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.share")}</VerbLabel>
</Button>
{/* Solid destructive (matching the detail sheet's Delete) — the
outline variant's destructive text on the card surface fails the
WCAG contrast gate. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onDelete}
aria-label={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
<span className="hidden sm:inline">{t("documents.bulk.delete")}</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={onClear}
aria-label={t("documents.selection.clear")}
>
<X className="size-3.5" aria-hidden />
<span className="hidden sm:inline">
{t("documents.selection.clear")}
</span>
</Button>
WCAG contrast gate. Alone at the right edge, away from the safe
verbs. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onRequestDelete}
className={cn("ml-auto", VERB_CLASS)}
data-slot="document-bulk-delete"
aria-label={t("documents.bulk.delete")}
title={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
<VerbLabel>{t("documents.bulk.delete")}</VerbLabel>
</Button>
</div>
</div>
</div>
);
@@ -38,6 +38,7 @@ import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -442,6 +443,8 @@ export function DocumentDetailSheet({
/** Bumped on a failed write; remounts the link block onto server state. */
const [linkResets, setLinkResets] = useState(0);
const [shareOpen, setShareOpen] = useState(false);
// Delete asks first, like every other way of deleting from the vault.
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
/**
* The visit this SESSION filed the document against, if any.
@@ -769,8 +772,9 @@ export function DocumentDetailSheet({
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
onClick={() => setConfirmDeleteOpen(true)}
disabled={remove.isPending}
data-slot="document-detail-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
@@ -1214,6 +1218,19 @@ export function DocumentDetailSheet({
documents={[{ id: doc.id, title }]}
/>
) : null}
{doc ? (
<ConfirmDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
title={t("documents.deleteConfirm.titleOne")}
body={t("documents.deleteConfirm.body")}
confirmLabel={t("documents.deleteConfirm.action")}
onConfirm={() => remove.mutate(doc.id)}
pending={remove.isPending}
slot="document-detail-delete"
destructive
/>
) : null}
</>
);
}
@@ -22,6 +22,20 @@ export function columnsForWidth(width: number): number {
return 1;
}
/**
* Measured width → compact rows side by side in the list view. Stacked, the
* list is one column; flowing, it fills the width the way the tiles do, with
* wider cells because a row carries its title beside the preview.
*/
export function listColumnsForWidth(width: number): number {
if (width >= 1100) return 3;
if (width >= 700) return 2;
return 1;
}
/** Height the flowing arrangement reserves above each row for month names. */
export const FLOW_MONTH_LABEL_PX = 24;
/** Gap between tiles and between rows, in px (`gap-4` / `pb-4`). */
const GRID_GAP_PX = 16;
@@ -29,10 +43,17 @@ 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 {
export function estimatedRowHeight(
width: number,
columns: number,
view: "cards" | "list" = "cards",
flow = false,
): number {
const extra = flow ? FLOW_MONTH_LABEL_PX - GRID_GAP_PX : 0;
if (view === "list") return 84 + GRID_GAP_PX + extra;
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;
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX + extra;
}
export function DocumentMonthHeading({ label }: { label: string }) {
@@ -43,9 +64,32 @@ export function DocumentMonthHeading({ label }: { label: string }) {
);
}
/**
* The flowing arrangement's month marker: the month's name and a hairline
* that runs to the edge of its cell, sitting in the space above the first
* document of that month. It marks where a month begins without taking a row
* of its own, so the documents keep running across the width.
*/
function FlowMonthMarker({ label }: { label: string }) {
return (
<div
data-slot="document-flow-month"
className="absolute inset-x-0 -top-6 flex h-6 items-center gap-2"
>
<span className="text-muted-foreground shrink-0 truncate text-xs font-medium tracking-wide uppercase">
{label}
</span>
<span aria-hidden className="bg-border h-px flex-1" />
</div>
);
}
export function DocumentMonthRow({
documents,
columns,
view = "cards",
monthStarts,
formatMonth,
selectedIds,
onToggleSelected,
onOpen,
@@ -57,6 +101,12 @@ export function DocumentMonthRow({
}: {
documents: InboundDocumentDto[];
columns: number;
/** `cards` = preview tiles, `list` = compact rows. */
view?: "cards" | "list";
/** Flowing arrangement: documents that open a month → that month's key. */
monthStarts?: Record<string, string>;
/** YYYY-MM → the reader's month label; needed with `monthStarts`. */
formatMonth?: (key: string) => string;
selectedIds: ReadonlySet<string>;
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
@@ -66,27 +116,40 @@ export function DocumentMonthRow({
onCardFocus: (id: string) => void;
onPrefetch?: (id: string) => void;
}) {
const flow = monthStarts !== undefined;
return (
<div
data-slot="document-month-row"
className="grid gap-4 pb-4"
className={flow ? "grid gap-4 pt-6" : "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}
/>
))}
{documents.map((doc) => {
const monthKey = monthStarts?.[doc.id];
const card = (
<DocumentCard
key={doc.id}
variant={view === "list" ? "row" : "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}
/>
);
if (!flow) return card;
return (
<div key={doc.id} className="relative min-w-0">
{monthKey ? (
<FlowMonthMarker label={formatMonth?.(monthKey) ?? monthKey} />
) : null}
{card}
</div>
);
})}
</div>
);
}
+47 -9
View File
@@ -31,14 +31,23 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { UploadStateCard } from "./document-card";
import type {
DocumentsLayoutArrangement,
DocumentsLayoutView,
} from "@/lib/documents/documents-layout";
import {
columnsForWidth,
DocumentMonthHeading,
DocumentMonthRow,
estimatedRowHeight,
listColumnsForWidth,
} from "./document-month-grid";
import type { UploadQueueItem } from "./use-document-upload";
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
import {
buildFlowTimelineItems,
buildTimelineItems,
formatMonthLabel,
} from "./vault-utils";
const SCROLL_CONTAINER_ID = "main-content";
@@ -56,7 +65,13 @@ export function DocumentTimeline({
highlightId,
onPrefetch,
timezone,
view = "cards",
arrangement = "stacked",
}: {
/** Preview tiles or compact rows (the reader's vault presentation). */
view?: DocumentsLayoutView;
/** Months as their own blocks, or one continuous run across the width. */
arrangement?: DocumentsLayoutArrangement;
/** The reader's profile zone; an undated document files under its upload day there. */
timezone: string;
documents: InboundDocumentDto[];
@@ -85,24 +100,39 @@ export function DocumentTimeline({
// the remembered card left the corpus (filter change, deletion).
const [activeId, setActiveId] = useState<string | null>(null);
// Measured columns — the ResizeObserver drives the row chunking.
const flow = arrangement === "flow";
// Measured columns — the ResizeObserver drives the row chunking. Tiles
// share a row on any width; compact rows only in the flowing arrangement,
// a stacked list stays one column.
useEffect(() => {
const el = listRef.current;
if (!el) return;
const update = () => {
setColumns(columnsForWidth(el.clientWidth));
setGridWidth(el.clientWidth);
const width = el.clientWidth;
setColumns(
view === "cards"
? columnsForWidth(width)
: flow
? listColumnsForWidth(width)
: 1,
);
setGridWidth(width);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
}, [view, flow]);
const items = useMemo(
() => buildTimelineItems(documents, columns, timezone),
[documents, columns, timezone],
() =>
flow
? buildFlowTimelineItems(documents, columns, timezone)
: buildTimelineItems(documents, columns, timezone),
[documents, columns, timezone, flow],
);
const formatMonth = (key: string) => formatMonthLabel(key, locale);
// The timeline does not start at the scrollport's top edge (page header,
// filter bar, upload row sit above it) — feed the offset to the
@@ -134,7 +164,7 @@ export function DocumentTimeline({
estimateSize: (index) =>
items[index].type === "month"
? 40
: estimatedRowHeight(gridWidth, columns),
: estimatedRowHeight(gridWidth, columns, view, flow),
getItemKey: (index) => items[index].key,
overscan: 6,
scrollMargin,
@@ -237,7 +267,12 @@ export function DocumentTimeline({
]);
return (
<div data-slot="document-timeline" className="space-y-4">
<div
data-slot="document-timeline"
data-view={view}
data-arrangement={arrangement}
className="space-y-4"
>
{uploadItems.length > 0 ? (
<div
data-slot="document-upload-queue"
@@ -296,6 +331,9 @@ export function DocumentTimeline({
<DocumentMonthRow
documents={item.documents}
columns={columns}
view={view}
monthStarts={item.monthStarts}
formatMonth={formatMonth}
selectedIds={selectedIds}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
+107 -28
View File
@@ -27,13 +27,16 @@ import {
SearchX,
Upload,
UploadCloud,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -56,6 +59,7 @@ import {
useCoachLaunch,
} from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
@@ -408,6 +412,10 @@ export function DocumentsView() {
[list.data],
);
// The reader's presentation (cards / list, stacked / flowing months),
// persisted per user and changed behind the wrench.
const { layout, isLayoutLoading } = useDocumentsLayout(moduleEnabled);
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/
// measurements/medications. The vault is one of the sharpest PWA-resume-
// staleness surfaces named in the audit: a document indexed server-side
@@ -755,6 +763,21 @@ export function DocumentsView() {
[bulk, clearSelection, restoreBulk, t],
);
// ── Delete confirmation ───────────────────────────────────────────────
// Every way of deleting from the vault page — the bulk bar's Delete and a
// card's Delete key — comes through here and asks first. Nothing on this
// page calls `deleteBulk` directly.
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(
null,
);
const requestDelete = useCallback((ids: string[]) => {
if (ids.length > 0) setPendingDeleteIds(ids);
}, []);
const confirmDelete = useCallback(() => {
if (pendingDeleteIds) deleteBulk(pendingDeleteIds);
setPendingDeleteIds(null);
}, [pendingDeleteIds, deleteBulk]);
// ── Bulk share ────────────────────────────────────────────────────────
// ONE documents-only link for the whole selection (the share model carries
// up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded
@@ -934,17 +957,42 @@ export function DocumentsView() {
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
canManageDocuments ? (
canManageDocuments || !inSharedRecord ? (
<div className="flex flex-wrap items-center gap-2">
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
{/* The wrench left of the primary action, as on the illness
journal: it opens this vault's display settings. `/settings`
is the caller's own, so it never shows inside somebody
else's record. */}
{!inSharedRecord ? (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/documents"
aria-label={t("documents.customize")}
title={t("documents.customize")}
data-slot="documents-customize"
>
<Wrench className="size-4" aria-hidden />
</Link>
</Button>
) : null}
{canManageDocuments ? (
<>
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
</>
) : null}
</div>
) : null
}
@@ -990,22 +1038,30 @@ export function DocumentsView() {
onIndexAll={handleIndexAll}
/>
{list.isPending ? (
<div
data-slot="documents-loading"
// 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: 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.isPending || isLayoutLoading ? (
layout.view === "list" ? (
<div data-slot="documents-loading" className="space-y-4">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : (
<div
data-slot="documents-loading"
// 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: 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 ? (
<QueryErrorCard
title={t("documents.list.loadError")}
@@ -1042,6 +1098,8 @@ export function DocumentsView() {
/>
) : (
<DocumentTimeline
view={layout.view}
arrangement={layout.arrangement}
timezone={timezone}
documents={documents}
uploadItems={upload.items}
@@ -1052,7 +1110,9 @@ export function DocumentsView() {
selectedIds={selectedIds}
onToggleSelected={canManageDocuments ? toggleSelected : undefined}
onOpen={openDetail}
onDelete={canManageDocuments ? (id) => deleteBulk([id]) : undefined}
onDelete={
canManageDocuments ? (id) => requestDelete([id]) : undefined
}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
@@ -1081,11 +1141,30 @@ export function DocumentsView() {
runBulk("linkEncounter", { encounterId })
}
onShare={openBulkShare}
onDelete={() => deleteBulk([...selectedIds])}
onRequestDelete={() => requestDelete([...selectedIds])}
onClear={clearSelection}
/>
) : null}
<ConfirmDialog
open={pendingDeleteIds !== null}
onOpenChange={(open) => {
if (!open) setPendingDeleteIds(null);
}}
title={
pendingDeleteIds !== null && pendingDeleteIds.length > 1
? t("documents.deleteConfirm.titleMany", {
count: pendingDeleteIds.length,
})
: t("documents.deleteConfirm.titleOne")
}
body={t("documents.deleteConfirm.body")}
confirmLabel={t("documents.deleteConfirm.action")}
onConfirm={confirmDelete}
slot="documents-delete"
destructive
/>
<DocumentShareSheet
open={bulkShareOpen}
onOpenChange={setBulkShareOpen}
+45 -1
View File
@@ -185,7 +185,17 @@ export function hasProcessingDocument(
*/
export type TimelineItem =
| { type: "month"; key: string }
| { type: "row"; key: string; documents: InboundDocumentDto[] };
| {
type: "row";
key: string;
documents: InboundDocumentDto[];
/**
* Flowing arrangement only: the documents in this row that open a
* month, mapped to that month's YYYY-MM key. The month name rides
* inline above the document instead of taking a row of its own.
*/
monthStarts?: Record<string, string>;
};
/**
* Bucket an already-sorted document list into consecutive YYYY-MM sections
@@ -224,6 +234,40 @@ export function buildTimelineItems(
return items;
}
/**
* The flowing arrangement: documents run left to right across every row and
* wrap, with no row reserved for a month. The first document of each month
* carries that month in `monthStarts`, so its name can sit inline above it.
* Order is preserved exactly as received, like `buildTimelineItems`.
*/
export function buildFlowTimelineItems(
documents: InboundDocumentDto[],
columns: number,
timezone: string,
): TimelineItem[] {
const cols = Math.max(1, columns);
const items: TimelineItem[] = [];
let month: string | null = null;
for (let i = 0; i < documents.length; i += cols) {
const row = documents.slice(i, i + cols);
const monthStarts: Record<string, string> = {};
for (const doc of row) {
const key = documentMonthKey(doc, timezone);
if (key !== month) {
monthStarts[doc.id] = key;
month = key;
}
}
items.push({
type: "row",
key: `flow:${row[0].id}`,
documents: row,
monthStarts,
});
}
return items;
}
/**
* Format a YYYY-MM month key as a locale month label ("März 2026"). Handed
* over as a noon-UTC instant so the month never shifts under the display
@@ -29,7 +29,7 @@ import {
} from "../layout-groups";
describe("layout-groups registry", () => {
it("exposes the seven Appearance modules as statically-generated subpage params", () => {
it("exposes the eight Appearance modules as statically-generated subpage params", () => {
expect(LAYOUT_GROUP_IDS).toEqual([
"dashboard",
"insights",
@@ -37,6 +37,7 @@ describe("layout-groups registry", () => {
"mood",
"labs",
"illness",
"documents",
"vorsorge",
]);
// generateStaticParams() shape.
@@ -47,6 +48,7 @@ describe("layout-groups registry", () => {
{ module: "mood" },
{ module: "labs" },
{ module: "illness" },
{ module: "documents" },
{ module: "vorsorge" },
]);
});
@@ -0,0 +1,65 @@
"use client";
/**
* Settings → Appearance → Documents.
*
* Reached from the wrench beside the vault's Upload button, the way the
* illness journal's wrench reaches its own page. Two choices, each in its own
* card with the toggle in the header's status slot (the illness page's
* shape): the card/list view and the month arrangement. Both persist per user
* through `/api/documents/inbound/layout`.
*/
import { Eye, Rows3, WrapText } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
import { ViewToggle } from "@/components/ui/view-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
export function DocumentsSection() {
const { t } = useTranslations();
const { layout, setView, setArrangement } = useDocumentsLayout();
return (
<div className="space-y-6" data-slot="documents-layout-settings">
<SettingsCard id="documents-view" className="scroll-mt-28">
<SettingsCardHeader
icon={Eye}
title={t("moduleList.viewHeading")}
description={t("documents.layout.viewDescription")}
status={<ModuleViewToggle view={layout.view} onChange={setView} />}
/>
</SettingsCard>
<SettingsCard id="documents-arrangement" className="scroll-mt-28">
<SettingsCardHeader
icon={Rows3}
title={t("documents.layout.arrangementHeading")}
description={t("documents.layout.arrangementDescription")}
status={
<ViewToggle
view={layout.arrangement}
onChange={setArrangement}
groupLabel={t("documents.layout.arrangementHeading")}
dataSlotPrefix="documents-arrangement"
segments={[
{
value: "stacked",
label: t("documents.layout.stacked"),
icon: Rows3,
},
{
value: "flow",
label: t("documents.layout.flow"),
icon: WrapText,
},
]}
/>
}
/>
</SettingsCard>
</div>
);
}
+7
View File
@@ -8,6 +8,7 @@ import { MedicationsSection } from "./medications-section";
import { MoodSection } from "./mood-section";
import { LabsSection } from "./labs-section";
import { IllnessSection } from "./illness-section";
import { DocumentsSection } from "./documents-section";
import { VorsorgeSection } from "./vorsorge-section";
/**
@@ -93,6 +94,12 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
descriptionKey: "settings.sections.layout.illness.description",
Body: IllnessSection,
},
{
id: "documents",
titleKey: "settings.sections.layout.documents.title",
descriptionKey: "settings.sections.layout.documents.description",
Body: DocumentsSection,
},
{
id: "vorsorge",
titleKey: "settings.sections.layout.vorsorge.title",
+7
View File
@@ -54,6 +54,11 @@ interface ConfirmCopy {
slot?: string;
/** A control that belongs with the question, such as an opt-out checkbox. */
extra?: ReactNode;
/**
* The action deletes something: it wears the destructive style, as the
* data-list selection bar's confirm already does.
*/
destructive?: boolean;
}
function ConfirmBody({
@@ -64,6 +69,7 @@ function ConfirmBody({
pending = false,
slot,
extra,
destructive = false,
}: ConfirmCopy) {
const { t } = useTranslations();
@@ -77,6 +83,7 @@ function ConfirmBody({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
data-slot={slot ? `${slot}-confirm` : undefined}
disabled={pending}
aria-busy={pending || undefined}