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

+120
View File
@@ -17386,6 +17386,46 @@ paths:
"403": *a11
"422": *a4
"429": *a5
/api/documents/inbound/layout:
get:
tags:
- Documents
summary: Get the vault presentation
description: The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has
been chosen. Inside a shared record this answers the owner's choice.
responses:
"200":
description: The resolved presentation.
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentsLayoutEnvelope"
"401": *a1
"403": *a11
"422": *a4
"429": *a5
put:
tags:
- Documents
summary: Change the vault presentation
description: "Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the
full resolved presentation. Refused while acting inside somebody else's record."
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentsLayoutPutBody"
responses:
"200":
description: The presentation as stored.
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentsLayoutSavedEnvelope"
"401": *a1
"422": *a4
"429": *a5
/api/documents/inbound/bulk:
post:
tags:
@@ -31275,6 +31315,26 @@ components:
additionalProperties: false
description: Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null`
`ageAtOnset`/`note` clears that field. Rejects unknown keys.
DocumentsLayoutPutBody:
type: object
properties:
version:
type: number
const: 1
view:
type: string
enum:
- cards
- list
arrangement:
type: string
enum:
- stacked
- flow
required:
- version
additionalProperties: false
description: Either field may be omitted; the stored value is kept for the one left out.
DocumentBulkRequest:
type: object
properties:
@@ -36661,6 +36721,7 @@ components:
insightsLayoutJson: {}
medicationListLayoutJson: {}
moodTagLayoutJson: {}
documentsLayoutJson: {}
reportSelectionJson: {}
globalExcludedInjectionSites:
type: array
@@ -72136,6 +72197,65 @@ components:
- assistAvailable
- contentIndex
additionalProperties: false
DocumentsLayoutEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/DocumentsLayout"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
DocumentsLayout:
type: object
properties:
version:
type: number
const: 1
view:
type: string
enum:
- cards
- list
description: '`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".'
arrangement:
type: string
enum:
- stacked
- flow
description: '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with
month names inline. Default "stacked".'
required:
- version
- view
- arrangement
additionalProperties: false
description: Per-user document vault presentation. Display-only; a client that does not render a vault ignores it.
DocumentsLayoutSavedEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/DocumentsLayout"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
DocumentBulkEnvelope:
type: object
properties:
+2
View File
@@ -464,6 +464,8 @@ test.describe("document vault", () => {
const sheet = page.getByRole("dialog", { name: `${title}.pdf` });
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Delete" }).click();
// v1.42 — the sheet's Delete asks first, like every vault delete.
await page.locator('[data-slot="document-detail-delete-confirm"]').click();
// Undo toast → restore → the card returns.
//
+19
View File
@@ -5352,6 +5352,10 @@
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl."
},
"documents": {
"title": "Dokumente",
"description": "Ansicht und Anordnung der Monate."
}
},
"dashboard": {
@@ -11061,6 +11065,21 @@
"finishedHint": "Suche erneut oder ändere einen Filter, um weitere Dokumente auszuwählen.",
"maxReached": "Du kannst höchstens {max} Dokumente auf einmal importieren.",
"noMatchHint": "Versuche einen Teil des Namens oder setze Tag und Datum zurück."
},
"customize": "Darstellung anpassen",
"layout": {
"viewDescription": "Vorschaukarten mit Miniaturansicht oder eine kompakte Liste.",
"arrangementHeading": "Anordnung",
"arrangementDescription": "Jeder Monat als eigener Block oder alles fortlaufend über die ganze Breite.",
"stacked": "Monate untereinander",
"flow": "Fortlaufend",
"saveFailed": "Die Darstellung konnte nicht gespeichert werden."
},
"deleteConfirm": {
"titleOne": "Dieses Dokument löschen?",
"titleMany": "{count} Dokumente löschen?",
"body": "Direkt danach lässt sich das noch rückgängig machen. Nach 30 Tagen wird es endgültig entfernt.",
"action": "Löschen"
}
},
"selfDescription": {
+19
View File
@@ -5352,6 +5352,10 @@
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and archived tags for the mood picker."
},
"documents": {
"title": "Documents",
"description": "View and how the months are arranged."
}
},
"dashboard": {
@@ -11061,6 +11065,21 @@
"finishedHint": "Search again or change a filter to pick more documents.",
"maxReached": "You can import up to {max} documents at a time.",
"noMatchHint": "Try part of the name, or clear the tag and dates."
},
"customize": "Customize view",
"layout": {
"viewDescription": "Preview cards with thumbnails, or a compact list.",
"arrangementHeading": "Arrangement",
"arrangementDescription": "Each month as its own block, or everything flowing across the full width.",
"stacked": "Months stacked",
"flow": "Continuous",
"saveFailed": "Couldn't save the view."
},
"deleteConfirm": {
"titleOne": "Delete this document?",
"titleMany": "Delete {count} documents?",
"body": "You can undo this right afterwards. After 30 days it is removed for good.",
"action": "Delete"
}
},
"selfDescription": {
+19
View File
@@ -5352,6 +5352,10 @@
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo."
},
"documents": {
"title": "Documentos",
"description": "Vista y disposición de los meses."
}
},
"dashboard": {
@@ -11061,6 +11065,21 @@
"finishedHint": "Busca de nuevo o cambia un filtro para elegir más documentos.",
"maxReached": "Puedes importar como máximo {max} documentos a la vez.",
"noMatchHint": "Prueba con parte del nombre o quita la etiqueta y las fechas."
},
"customize": "Personalizar vista",
"layout": {
"viewDescription": "Tarjetas con miniatura o una lista compacta.",
"arrangementHeading": "Disposición",
"arrangementDescription": "Cada mes en su propio bloque o todo seguido a lo ancho.",
"stacked": "Meses apilados",
"flow": "Continuo",
"saveFailed": "No se pudo guardar la vista."
},
"deleteConfirm": {
"titleOne": "¿Eliminar este documento?",
"titleMany": "¿Eliminar {count} documentos?",
"body": "Puedes deshacerlo justo después. A los 30 días se elimina definitivamente.",
"action": "Eliminar"
}
},
"selfDescription": {
+19
View File
@@ -5352,6 +5352,10 @@
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur."
},
"documents": {
"title": "Documents",
"description": "Affichage et disposition des mois."
}
},
"dashboard": {
@@ -11061,6 +11065,21 @@
"finishedHint": "Cherchez à nouveau ou changez un filtre pour choisir d'autres documents.",
"maxReached": "Vous pouvez importer au plus {max} documents à la fois.",
"noMatchHint": "Essayez une partie du nom, ou effacez l'étiquette et les dates."
},
"customize": "Personnaliser l'affichage",
"layout": {
"viewDescription": "Des cartes avec aperçu ou une liste compacte.",
"arrangementHeading": "Disposition",
"arrangementDescription": "Chaque mois dans son propre bloc, ou tout à la suite sur toute la largeur.",
"stacked": "Mois empilés",
"flow": "En continu",
"saveFailed": "Impossible d'enregistrer l'affichage."
},
"deleteConfirm": {
"titleOne": "Supprimer ce document ?",
"titleMany": "Supprimer {count} documents ?",
"body": "Vous pouvez annuler juste après. Au bout de 30 jours, la suppression est définitive.",
"action": "Supprimer"
}
},
"selfDescription": {
+19
View File
@@ -5352,6 +5352,10 @@
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e tag archiviati del selettore dell’umore."
},
"documents": {
"title": "Documenti",
"description": "Visualizzazione e disposizione dei mesi."
}
},
"dashboard": {
@@ -11061,6 +11065,21 @@
"finishedHint": "Cerca di nuovo o cambia un filtro per scegliere altri documenti.",
"maxReached": "Puoi importare al massimo {max} documenti alla volta.",
"noMatchHint": "Prova con parte del nome, oppure azzera etichetta e date."
},
"customize": "Personalizza visualizzazione",
"layout": {
"viewDescription": "Schede con anteprima oppure un elenco compatto.",
"arrangementHeading": "Disposizione",
"arrangementDescription": "Ogni mese in un blocco a sé oppure tutto di seguito su tutta la larghezza.",
"stacked": "Mesi impilati",
"flow": "Continuo",
"saveFailed": "Impossibile salvare la visualizzazione."
},
"deleteConfirm": {
"titleOne": "Eliminare questo documento?",
"titleMany": "Eliminare {count} documenti?",
"body": "Puoi annullare subito dopo. Dopo 30 giorni viene rimosso definitivamente.",
"action": "Elimina"
}
},
"selfDescription": {
+19
View File
@@ -5352,6 +5352,10 @@
"mood": {
"title": "기분 태그",
"description": "기분 선택기의 그룹, 태그, 보관된 태그."
},
"documents": {
"title": "문서",
"description": "보기 방식과 월 배치."
}
},
"dashboard": {
@@ -11061,6 +11065,21 @@
"finishedHint": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.",
"maxReached": "한 번에 최대 {max}개 문서를 가져올 수 있습니다.",
"noMatchHint": "이름의 일부로 검색하거나 태그와 날짜를 지워 보세요."
},
"customize": "보기 사용자 지정",
"layout": {
"viewDescription": "미리보기가 있는 카드 또는 간단한 목록.",
"arrangementHeading": "배치",
"arrangementDescription": "월별로 따로 묶거나 전체 너비에 이어서 표시합니다.",
"stacked": "월별로 나누기",
"flow": "이어서 보기",
"saveFailed": "보기 설정을 저장하지 못했습니다."
},
"deleteConfirm": {
"titleOne": "이 문서를 삭제할까요?",
"titleMany": "문서 {count}개를 삭제할까요?",
"body": "삭제 직후에는 되돌릴 수 있습니다. 30일이 지나면 완전히 삭제됩니다.",
"action": "삭제"
}
},
"selfDescription": {
+19
View File
@@ -5352,6 +5352,10 @@
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju."
},
"documents": {
"title": "Dokumenty",
"description": "Widok i układ miesięcy."
}
},
"dashboard": {
@@ -11061,6 +11065,21 @@
"finishedHint": "Wyszukaj ponownie lub zmień filtr, aby wybrać więcej dokumentów.",
"maxReached": "Możesz zaimportować naraz najwyżej {max} dokumentów.",
"noMatchHint": "Spróbuj części nazwy albo wyczyść tag i daty."
},
"customize": "Dostosuj widok",
"layout": {
"viewDescription": "Karty z miniaturą albo zwarta lista.",
"arrangementHeading": "Układ",
"arrangementDescription": "Każdy miesiąc osobno albo wszystko po kolei na całą szerokość.",
"stacked": "Miesiące jeden pod drugim",
"flow": "Ciągły",
"saveFailed": "Nie udało się zapisać widoku."
},
"deleteConfirm": {
"titleOne": "Usunąć ten dokument?",
"titleMany": "Usunąć dokumenty ({count})?",
"body": "Zaraz potem możesz to cofnąć. Po 30 dniach zostanie usunięte na stałe.",
"action": "Usuń"
}
},
"selfDescription": {
@@ -0,0 +1,3 @@
-- Per-user document vault presentation (view + month arrangement).
-- Nullable, display-only; null reads as the defaults.
ALTER TABLE "users" ADD COLUMN "documents_layout_json" JSONB;
+6
View File
@@ -310,6 +310,12 @@ model User {
// once the user has explicitly toggled the view or saved an order.
// Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
medicationListLayoutJson Json? @map("medication_list_layout_json")
// v1.42 — per-user document vault presentation: the card/list view and
// the stacked/flowing month arrangement. Display-only, preserve-when-absent
// PUT on `/api/documents/inbound/layout`, resolved field by field on read
// (`src/lib/documents/documents-layout.ts`). Null = defaults (cards, stacked).
// Shape: { "version": 1, "view": "list", "arrangement": "flow" }
documentsLayoutJson Json? @map("documents_layout_json")
// v1.17.0 — per-user mood-tag presentation: group order + per-group tag
// placement (catalogue tags "moved" into a group live here only — their
// `categoryId` never changes). Display-only, preserve-when-absent PUT,
+8 -1
View File
@@ -1190,6 +1190,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "documents",
why: "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.",
},
"app/api/documents/inbound/layout/route.ts": {
domain: "documents",
why: "The record's vault presentation, read only: two closed enums (card or list view, stacked or flowing months) and nothing else. The admitted list renders through it, so a refused read would leave a delegate's vault waiting on a shape it can never learn. The PUT stays bare — how the owner's vault is laid out is the owner's choice, the same split `/api/medications/layout` keeps.",
},
/* ------------------------------------------------------------------ */
/* v1.37.0 — the fifty-one modules that became delegable at MANAGE. */
@@ -2086,8 +2090,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* the life-event writes join it with their audit rows: the edit/delete module
* on the record list, the create and the edit/delete on the manage literal.
* 251 -> 255.
*
* v1.42 -- the document vault's presentation read joins the record list
* beside the rest of the vault; its PUT stays bare. 255 -> 256.
*/
const FROZEN_ENTRY_COUNT = 255;
const FROZEN_ENTRY_COUNT = 256;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -0,0 +1,167 @@
/**
* `/api/documents/inbound/layout` — the vault presentation.
*
* GET answers the defaults for an account that never chose, without writing
* a row; PUT is preserve-when-absent per field, refuses unknown values and
* unknown keys with the multi-issue 422, and both halves stop at the
* documents module gate.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { DEFAULT_DOCUMENTS_LAYOUT } from "@/lib/documents/documents-layout";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
displayName: null,
},
};
const callGet = GET as unknown as () => Promise<Response>;
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
function put(body: unknown): Promise<Response> {
return callPut(
new NextRequest("http://localhost/api/documents/inbound/layout", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
describe("GET /api/documents/inbound/layout", () => {
it("answers the defaults and writes nothing for an account that never chose", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsLayoutJson: null,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual(DEFAULT_DOCUMENTS_LAYOUT);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("serves the stored choice, repairing an unknown value field by field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsLayoutJson: { version: 1, view: "list", arrangement: "spiral" },
} as never);
const res = await callGet();
expect((await res.json()).data).toEqual({
version: 1,
view: "list",
arrangement: "stacked",
});
});
it("stops at the module gate", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
const res = await callGet();
expect(res.status).toBe(403);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
});
describe("PUT /api/documents/inbound/layout", () => {
it("keeps the stored arrangement when only the view changes", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsLayoutJson: { version: 1, view: "cards", arrangement: "flow" },
} as never);
const res = await put({ version: 1, view: "list" });
expect(res.status).toBe(200);
const expected = { version: 1, view: "list", arrangement: "flow" };
expect((await res.json()).data).toEqual(expected);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { documentsLayoutJson: expected },
});
});
it("keeps the stored view when only the arrangement changes", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsLayoutJson: { version: 1, view: "list", arrangement: "stacked" },
} as never);
const res = await put({ version: 1, arrangement: "flow" });
expect((await res.json()).data).toEqual({
version: 1,
view: "list",
arrangement: "flow",
});
});
it("refuses an unknown value and an unknown key with 422 and writes nothing", async () => {
const bad = await put({ version: 1, view: "carousel" });
expect(bad.status).toBe(422);
const extra = await put({ version: 1, view: "list", userId: "user-2" });
expect(extra.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("stops at the module gate before reading the body", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
const res = await put({ version: 1, view: "list" });
expect(res.status).toBe(403);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,99 @@
/**
* GET / PUT the document vault presentation (view + month arrangement).
*
* GET returns the resolved presentation, defaults merged in when the user has
* not chosen yet. PUT changes it with preserve-when-absent semantics: a body
* carrying only `view` keeps the stored arrangement and vice versa, the same
* contract `/api/medications/layout` keeps for its view and order. The blob
* lives on its own `User` column (`documents_layout_json`) per the
* per-surface-column convention.
*
* Both halves sit behind the documents module gate like every other route
* under `/api/documents/inbound`.
*/
import { z } from "zod/v4";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { prisma, toJson } from "@/lib/db";
import {
DOCUMENTS_LAYOUT_ARRANGEMENTS,
DOCUMENTS_LAYOUT_VIEWS,
resolveDocumentsLayout,
type DocumentsLayout,
} from "@/lib/documents/documents-layout";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
const layoutPutSchema = z
.object({
version: z.literal(1),
view: z.enum(DOCUMENTS_LAYOUT_VIEWS).optional(),
arrangement: z.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS).optional(),
})
.strict();
async function readLayout(userId: string): Promise<DocumentsLayout> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { documentsLayoutJson: true },
});
return resolveDocumentsLayout(row?.documentsLayoutJson);
}
export const GET = apiHandler(async () => {
// Read: the RECORD's presentation, so a delegate browsing somebody's vault
// sees it the way its owner arranged it. The write below stays the
// caller's own and refuses under a switch.
const { user } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const layout = await readLayout(user.id);
annotate({
action: { name: "documents.layout.read" },
meta: { view: layout.view, arrangement: layout.arrangement },
});
return apiSuccess(layout);
});
export const PUT = apiHandler(async (request: Request) => {
// Bare on purpose: how the owner's vault is laid out is the owner's choice,
// not something a helper rearranges on their behalf.
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = layoutPutSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "documents.layout.validation-failed" } });
return returnAllZodIssues(parsed.error, 422);
}
// Preserve-when-absent: one stored read fills whichever field the body
// left out.
const existing = await readLayout(user.id);
const next: DocumentsLayout = {
version: 1,
view: parsed.data.view ?? existing.view,
arrangement: parsed.data.arrangement ?? existing.arrangement,
};
await prisma.user.update({
where: { id: user.id },
data: { documentsLayoutJson: toJson(next) },
});
annotate({
action: { name: "documents.layout.update" },
meta: { view: next.view, arrangement: next.arrangement },
});
return apiSuccess(next);
});
@@ -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}
+1
View File
@@ -454,6 +454,7 @@ export const USER_RESET = {
dashboardWidgetsJson: Prisma.DbNull,
medicationListLayoutJson: Prisma.DbNull,
moodTagLayoutJson: Prisma.DbNull,
documentsLayoutJson: Prisma.DbNull,
reportSelectionJson: Prisma.DbNull,
modulePreferencesJson: Prisma.DbNull,
healthScoreConfigJson: Prisma.DbNull,
+70
View File
@@ -0,0 +1,70 @@
/**
* Document vault presentation — persisted in `User.documentsLayoutJson`.
*
* Two independent choices, both display-only:
*
* - `view`: `cards` is the monthly grid of preview tiles; `list` is one
* compact row per document.
* - `arrangement`: `stacked` gives every month its own block, one under the
* other; `flow` lets the documents run left to right across the full width
* and wrap, with each month's name riding inline as a small marker that does
* not break the run.
*
* Null / missing column = the defaults below (cards, stacked — the vault as it
* has always looked). The GET endpoint never lazy-writes, so the column only
* carries data once the user has changed one of the two. Mirrors
* `medication-list-layout.ts`: tolerant resolver on read, normaliser on write,
* preserve-when-absent PUT (see the route).
*/
export const DOCUMENTS_LAYOUT_VIEWS = ["cards", "list"] as const;
export type DocumentsLayoutView = (typeof DOCUMENTS_LAYOUT_VIEWS)[number];
export const DOCUMENTS_LAYOUT_ARRANGEMENTS = ["stacked", "flow"] as const;
export type DocumentsLayoutArrangement =
(typeof DOCUMENTS_LAYOUT_ARRANGEMENTS)[number];
export interface DocumentsLayout {
version: 1;
view: DocumentsLayoutView;
arrangement: DocumentsLayoutArrangement;
}
export const DEFAULT_DOCUMENTS_LAYOUT: DocumentsLayout = {
version: 1,
view: "cards",
arrangement: "stacked",
};
function isView(value: unknown): value is DocumentsLayoutView {
return (
typeof value === "string" &&
(DOCUMENTS_LAYOUT_VIEWS as readonly string[]).includes(value)
);
}
function isArrangement(value: unknown): value is DocumentsLayoutArrangement {
return (
typeof value === "string" &&
(DOCUMENTS_LAYOUT_ARRANGEMENTS as readonly string[]).includes(value)
);
}
/**
* Any malformed, partial or legacy blob collapses onto the defaults field by
* field, so a GET never fails on a stored row and a field added later
* defaults cleanly for blobs written before it existed.
*/
export function resolveDocumentsLayout(raw: unknown): DocumentsLayout {
if (raw === null || raw === undefined || typeof raw !== "object") {
return { ...DEFAULT_DOCUMENTS_LAYOUT };
}
const blob = raw as { view?: unknown; arrangement?: unknown };
return {
version: 1,
view: isView(blob.view) ? blob.view : DEFAULT_DOCUMENTS_LAYOUT.view,
arrangement: isArrangement(blob.arrangement)
? blob.arrangement
: DEFAULT_DOCUMENTS_LAYOUT.arrangement,
};
}
@@ -524,6 +524,7 @@ const ACCOUNT_SETTING_CODECS: Readonly<
insightsLayoutJson: json("insightsLayoutJson"),
medicationListLayoutJson: json("medicationListLayoutJson"),
moodTagLayoutJson: json("moodTagLayoutJson"),
documentsLayoutJson: json("documentsLayoutJson"),
reportSelectionJson: json("reportSelectionJson"),
globalExcludedInjectionSites: excludedSites,
healthKitConfigJson: json("healthKitConfigJson"),
+1
View File
@@ -950,6 +950,7 @@ export const USER_COLUMN_BACKUP_CLASS = {
insightsLayoutJson: "SETTING",
medicationListLayoutJson: "SETTING",
moodTagLayoutJson: "SETTING",
documentsLayoutJson: "SETTING",
reportSelectionJson: "SETTING",
globalExcludedInjectionSites: "SETTING",
healthKitConfigJson: "SETTING",
+1
View File
@@ -200,6 +200,7 @@ const STATIC_SURFACE_MODULE = {
"settings-layout:mood": "mood",
"settings-layout:labs": "labs",
"settings-layout:illness": "illness",
"settings-layout:documents": "inboundDocuments",
// ── Correlation channels ──
// Rated mood factors (`FACTOR:<key>`) are mood's too; see
+86
View File
@@ -30,6 +30,10 @@ import {
INBOUND_DOCUMENT_KINDS,
INBOUND_DOCUMENT_STATUSES,
} from "@/lib/validations/inbound-documents";
import {
DOCUMENTS_LAYOUT_ARRANGEMENTS,
DOCUMENTS_LAYOUT_VIEWS,
} from "@/lib/documents/documents-layout";
import { aiExtractionRefusals } from "./ai-extraction-refusals";
import { aiRunAccepted, preferRespondAsyncParameter } from "./ai-run-accepted";
@@ -44,6 +48,41 @@ import {
} from "./shared";
const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS);
// ─── Vault presentation (v1.42) ────────────────────────────────────────────
const documentsLayoutSchema = z
.object({
version: z.literal(1),
view: z
.enum(DOCUMENTS_LAYOUT_VIEWS)
.describe(
'`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".',
),
arrangement: z
.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS)
.describe(
'`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with month names inline. Default "stacked".',
),
})
.meta({
id: "DocumentsLayout",
description:
"Per-user document vault presentation. Display-only; a client that does not render a vault ignores it.",
});
const documentsLayoutPutBody = z
.object({
version: z.literal(1),
view: z.enum(DOCUMENTS_LAYOUT_VIEWS).optional(),
arrangement: z.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS).optional(),
})
.strict()
.meta({
id: "DocumentsLayoutPutBody",
description:
"Either field may be omitted; the stored value is kept for the one left out.",
});
const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES);
// ─── Chat about a document (P4) — response shapes ──────────────────────────
@@ -719,6 +758,53 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/documents/inbound/layout": {
get: {
tags: ["Documents"],
summary: "Get the vault presentation",
description:
"The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has been chosen. Inside a shared record this answers the owner's choice.",
responses: {
...recordRefusal(),
"200": {
description: "The resolved presentation.",
content: {
"application/json": {
schema: dataEnvelope(
documentsLayoutSchema,
"DocumentsLayoutEnvelope",
),
},
},
},
...stdResponses,
},
},
put: {
tags: ["Documents"],
summary: "Change the vault presentation",
description:
"Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the full resolved presentation. Refused while acting inside somebody else's record.",
requestBody: {
required: true,
content: { "application/json": { schema: documentsLayoutPutBody } },
},
responses: {
"200": {
description: "The presentation as stored.",
content: {
"application/json": {
schema: dataEnvelope(
documentsLayoutSchema,
"DocumentsLayoutSavedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/documents/inbound/bulk": {
post: {
tags: ["Documents"],
@@ -0,0 +1,85 @@
/**
* The vault presentation switch: field-scoped PUT, optimistic cache flip,
* rollback and a toast when the save fails. Exercised through the
* dependency-injected `runSetDocumentsLayout`, without a React render.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiPut: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() },
}));
import { apiPut } from "@/lib/api/api-fetch";
import { toast } from "sonner";
import { runSetDocumentsLayout } from "@/lib/queries/use-documents-layout";
import { queryKeys } from "@/lib/query-keys";
import type { DocumentsLayout } from "@/lib/documents/documents-layout";
const t = (key: string) => key;
const key = queryKeys.documentsLayout();
const STORED: DocumentsLayout = {
version: 1,
view: "cards",
arrangement: "stacked",
};
beforeEach(() => {
vi.resetAllMocks();
});
describe("runSetDocumentsLayout", () => {
it("sends only the field that changed and keeps the server's answer", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(key, STORED);
const saved: DocumentsLayout = { ...STORED, arrangement: "flow" };
vi.mocked(apiPut).mockResolvedValue(saved);
await runSetDocumentsLayout({
patch: { arrangement: "flow" },
queryClient,
t,
});
expect(apiPut).toHaveBeenCalledWith("/api/documents/inbound/layout", {
version: 1,
arrangement: "flow",
});
expect(queryClient.getQueryData(key)).toEqual(saved);
});
it("flips the cache before the save lands", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(key, STORED);
let settle: (value: DocumentsLayout) => void = () => {};
vi.mocked(apiPut).mockReturnValue(
new Promise<DocumentsLayout>((resolve) => {
settle = resolve;
}),
);
const run = runSetDocumentsLayout({
patch: { view: "list" },
queryClient,
t,
});
expect(queryClient.getQueryData<DocumentsLayout>(key)?.view).toBe("list");
settle({ ...STORED, view: "list" });
await run;
});
it("rolls back and says so when the save fails", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(key, STORED);
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
await runSetDocumentsLayout({ patch: { view: "list" }, queryClient, t });
expect(queryClient.getQueryData(key)).toEqual(STORED);
expect(toast.error).toHaveBeenCalledWith("documents.layout.saveFailed");
});
});
+85
View File
@@ -0,0 +1,85 @@
"use client";
import {
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import {
DEFAULT_DOCUMENTS_LAYOUT,
type DocumentsLayout,
type DocumentsLayoutArrangement,
type DocumentsLayoutView,
} from "@/lib/documents/documents-layout";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
type LayoutPatch = {
view?: DocumentsLayoutView;
arrangement?: DocumentsLayoutArrangement;
};
/**
* Apply one field optimistically (the switch has to feel instant), then
* persist it. The PUT is field-scoped, so a view change never resets the
* arrangement and vice versa. On failure the cache rolls back and a calm
* toast says so.
*/
export async function runSetDocumentsLayout(deps: {
patch: LayoutPatch;
queryClient: QueryClient;
t: Translator;
}): Promise<void> {
const { patch, queryClient, t } = deps;
const key = queryKeys.documentsLayout();
const previous = queryClient.getQueryData<DocumentsLayout>(key);
queryClient.setQueryData<DocumentsLayout>(key, {
...(previous ?? DEFAULT_DOCUMENTS_LAYOUT),
...patch,
});
try {
const saved = await apiPut<DocumentsLayout>(
"/api/documents/inbound/layout",
{ version: 1, ...patch },
);
if (saved) queryClient.setQueryData(key, saved);
} catch {
queryClient.setQueryData(key, previous);
toast.error(t("documents.layout.saveFailed"));
}
}
/** The vault presentation, persisted per user. Defaults until the read lands. */
export function useDocumentsLayout(enabled: boolean = true): {
layout: DocumentsLayout;
isLayoutLoading: boolean;
setView: (view: DocumentsLayoutView) => Promise<void>;
setArrangement: (arrangement: DocumentsLayoutArrangement) => Promise<void>;
} {
const queryClient = useQueryClient();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.documentsLayout(),
queryFn: () => apiGet<DocumentsLayout>("/api/documents/inbound/layout"),
staleTime: 5 * 60 * 1000,
enabled,
});
return {
layout: data ?? DEFAULT_DOCUMENTS_LAYOUT,
isLayoutLoading: enabled && isLoading,
setView: (view) =>
runSetDocumentsLayout({ patch: { view }, queryClient, t }),
setArrangement: (arrangement) =>
runSetDocumentsLayout({ patch: { arrangement }, queryClient, t }),
};
}
+6
View File
@@ -30,6 +30,12 @@ export interface DocumentVaultFilters {
export const documentKeys = {
documents: () => ["documents"] as const,
/**
* The vault presentation (view + month arrangement). Deliberately outside
* the `["documents"]` prefix: a document write changes nothing about how
* the vault is laid out, so it must not refetch this.
*/
documentsLayout: () => ["documents-layout"] as const,
inboundDocuments: () => ["documents", "inbound"] as const,
/**
* The parameterised vault list (keyset-paginated via `useInfiniteQuery`).
+1
View File
@@ -2008,6 +2008,7 @@ const accountSettingsBackupSchema = z
insightsLayoutJson: z.unknown().optional(),
medicationListLayoutJson: z.unknown().optional(),
moodTagLayoutJson: z.unknown().optional(),
documentsLayoutJson: z.unknown().optional(),
reportSelectionJson: z.unknown().optional(),
globalExcludedInjectionSites: z.array(z.string()).optional(),
healthKitConfigJson: z.unknown().optional(),