From 4273af231e073f72c080ae2a59a4c72732d1b0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:45:06 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 120 +++++++ e2e/documents.spec.ts | 2 + messages/de.json | 19 + messages/en.json | 19 + messages/es.json | 19 + messages/fr.json | 19 + messages/it.json | 19 + messages/ko.json | 19 + messages/pl.json | 19 + .../0388_documents_layout/migration.sql | 3 + prisma/schema.prisma | 6 + src/__tests__/sharing-surface-guard.test.ts | 9 +- .../inbound/layout/__tests__/route.test.ts | 167 +++++++++ src/app/api/documents/inbound/layout/route.ts | 99 ++++++ .../__tests__/document-bulk-bar.test.tsx | 47 ++- .../__tests__/document-month-grid.test.tsx | 75 ++++ .../documents-delete-confirm-guard.test.ts | 55 +++ .../__tests__/documents-view-states.test.tsx | 32 +- .../documents/__tests__/vault-utils.test.ts | 23 ++ .../documents/document-bulk-bar.tsx | 324 ++++++++++-------- .../documents/document-detail-sheet.tsx | 19 +- .../documents/document-month-grid.tsx | 99 +++++- .../documents/document-timeline.tsx | 56 ++- src/components/documents/documents-view.tsx | 135 ++++++-- src/components/documents/vault-utils.ts | 46 ++- .../settings/__tests__/layout-groups.test.ts | 4 +- src/components/settings/documents-section.tsx | 65 ++++ src/components/settings/layout-groups.ts | 7 + src/components/ui/confirm-button.tsx | 7 + src/lib/data-wipe/wipe-plan.ts | 1 + src/lib/documents/documents-layout.ts | 70 ++++ src/lib/export/account-settings-backup.ts | 1 + src/lib/export/backup-plan.ts | 1 + src/lib/modules/surface.ts | 1 + src/lib/openapi/routes/documents.ts | 86 +++++ .../__tests__/use-documents-layout.test.ts | 85 +++++ src/lib/queries/use-documents-layout.ts | 85 +++++ src/lib/query-keys/documents.ts | 6 + src/lib/validations/backup.ts | 1 + 39 files changed, 1661 insertions(+), 209 deletions(-) create mode 100644 prisma/migrations/0388_documents_layout/migration.sql create mode 100644 src/app/api/documents/inbound/layout/__tests__/route.test.ts create mode 100644 src/app/api/documents/inbound/layout/route.ts create mode 100644 src/components/documents/__tests__/documents-delete-confirm-guard.test.ts create mode 100644 src/components/settings/documents-section.tsx create mode 100644 src/lib/documents/documents-layout.ts create mode 100644 src/lib/queries/__tests__/use-documents-layout.test.ts create mode 100644 src/lib/queries/use-documents-layout.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d9447ab1d..b66a81c6c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 4dc51327d..4cd77f256 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -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. // diff --git a/messages/de.json b/messages/de.json index 625afb001..7ed78a685 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index c8a52c09a..62f080bed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 238bb72f4..b0ba7065f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 0fd018599..6f8adf4a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 582cd2e10..a3fa178ae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 3fc16b737..fa7c8f328 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index f9487fb8f..8fc97f260 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/prisma/migrations/0388_documents_layout/migration.sql b/prisma/migrations/0388_documents_layout/migration.sql new file mode 100644 index 000000000..b7b5122c7 --- /dev/null +++ b/prisma/migrations/0388_documents_layout/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f24308c7b..72acf6c71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -310,6 +310,12 @@ model User { // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } 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, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 1324fca12..46ec7e4db 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1190,6 +1190,10 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/app/api/documents/inbound/layout/__tests__/route.test.ts b/src/app/api/documents/inbound/layout/__tests__/route.test.ts new file mode 100644 index 000000000..8c267af61 --- /dev/null +++ b/src/app/api/documents/inbound/layout/__tests__/route.test.ts @@ -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; +const callPut = PUT as unknown as (req: NextRequest) => Promise; + +function put(body: unknown): Promise { + 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(); + }); +}); diff --git a/src/app/api/documents/inbound/layout/route.ts b/src/app/api/documents/inbound/layout/route.ts new file mode 100644 index 000000000..45d3e2b94 --- /dev/null +++ b/src/app/api/documents/inbound/layout/route.ts @@ -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 { + 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); +}); diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx index 2770facfe..3ddea1b6c 100644 --- a/src/components/documents/__tests__/document-bulk-bar.test.tsx +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -29,7 +29,7 @@ function render( onLinkEpisode={() => {}} onLinkEncounter={() => {}} onShare={() => {}} - onDelete={() => {}} + onRequestDelete={() => {}} onClear={() => {}} /> , @@ -51,6 +51,51 @@ describe("", () => { 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 = /]*data-slot="document-bulk-actions"[^>]*>/.exec( + html, + ); + expect(rowTag?.[0]).not.toContain("flex-wrap"); + const deleteTag = /]*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( + /^
/, + ); + }); + it("omits the link-condition verb when the account has no episodes", () => { const html = render([]); expect(html).not.toContain("Link condition"); diff --git a/src/components/documents/__tests__/document-month-grid.test.tsx b/src/components/documents/__tests__/document-month-grid.test.tsx index c0b2a8a1b..360c2bb48 100644 --- a/src/components/documents/__tests__/document-month-grid.test.tsx +++ b/src/components/documents/__tests__/document-month-grid.test.tsx @@ -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[0]>) { + return renderToStaticMarkup( + + {}} + highlightId={null} + rovingId="a" + onCardFocus={() => {}} + {...props} + /> + , + ); +} + 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("", () => { expect(html).toMatch(/^

]*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("", () => { + 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"'); + }); +}); diff --git a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts new file mode 100644 index 000000000..abfde0ac0 --- /dev/null +++ b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts @@ -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(" { + 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\)\}/); + }); +}); diff --git a/src/components/documents/__tests__/documents-view-states.test.tsx b/src/components/documents/__tests__/documents-view-states.test.tsx index 56c270cf1..324b287a6 100644 --- a/src/components/documents/__tests__/documents-view-states.test.tsx +++ b/src/components/documents/__tests__/documents-view-states.test.tsx @@ -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(" 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 diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index ad9ddb2e9..cc515e682 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -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" }), diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 45d3a2069..ece68fb42 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -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 {children}; +} + 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 ( -
-

- {t("documents.selection.count", { count: selectedCount })} -

+ // 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. +
+
+
+

+ {t("documents.selection.count", { count: selectedCount })} +

+ +
-
- {/* 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. */} - - - - - - {DOCUMENT_KIND_ORDER.map((kind) => { - const Icon = DOCUMENT_KIND_ICONS[kind]; - return ( - onSetKind(kind)}> - - {t(`documents.kind.${kind}`)} - - ); - })} - - - - {episodes.length > 0 ? ( - - - - - - {episodes.map((episode) => ( - onLinkEpisode(episode.id)} - > - {episode.label} - - ))} - - - ) : null} - - {encounters.length > 0 ? ( - - - - - - {encounters.map((encounter) => ( - onLinkEncounter(encounter.id)} - > - {encounter.label} - - ))} - - - ) : null} - - + + + + + + {DOCUMENT_KIND_ORDER.map((kind) => { + const Icon = DOCUMENT_KIND_ICONS[kind]; + return ( + onSetKind(kind)}> + + {t(`documents.kind.${kind}`)} + + ); + })} + + - {/* Solid destructive (matching the detail sheet's Delete) — the + {episodes.length > 0 ? ( + + + + + + {episodes.map((episode) => ( + onLinkEpisode(episode.id)} + > + {episode.label} + + ))} + + + ) : null} + + {encounters.length > 0 ? ( + + + + + + {encounters.map((encounter) => ( + onLinkEncounter(encounter.id)} + > + {encounter.label} + + ))} + + + ) : null} + + + + {/* Solid destructive (matching the detail sheet's Delete) — the outline variant's destructive text on the card surface fails the - WCAG contrast gate. */} - - - + WCAG contrast gate. Alone at the right edge, away from the safe + verbs. */} + +
); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 396e1db28..740670268 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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({ + {/* 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 ? ( + + ) : null} + {canManageDocuments ? ( + <> + {/* Renders only with a connected Paperless-ngx or Papra. */} + + + + ) : null}
) : null } @@ -990,22 +1038,30 @@ export function DocumentsView() { onIndexAll={handleIndexAll} /> - {list.isPending ? ( -
- {Array.from({ length: 8 }, (_, i) => ( -
- - - -
- ))} -
+ {list.isPending || isLayoutLoading ? ( + layout.view === "list" ? ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+ ) : ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + + +
+ ))} +
+ ) ) : list.isError ? ( ) : ( 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} + { + 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 + /> + ; + }; /** * 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 = {}; + 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 diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts index a4a666ca4..b028df7dc 100644 --- a/src/components/settings/__tests__/layout-groups.test.ts +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -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" }, ]); }); diff --git a/src/components/settings/documents-section.tsx b/src/components/settings/documents-section.tsx new file mode 100644 index 000000000..f1e9b4f12 --- /dev/null +++ b/src/components/settings/documents-section.tsx @@ -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 ( +
+ + } + /> + + + + + } + /> + +
+ ); +} diff --git a/src/components/settings/layout-groups.ts b/src/components/settings/layout-groups.ts index 42f2edb77..5186a54bb 100644 --- a/src/components/settings/layout-groups.ts +++ b/src/components/settings/layout-groups.ts @@ -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 = [ 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", diff --git a/src/components/ui/confirm-button.tsx b/src/components/ui/confirm-button.tsx index 108f36f49..3f7022c9c 100644 --- a/src/components/ui/confirm-button.tsx +++ b/src/components/ui/confirm-button.tsx @@ -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({ {t("common.cancel")} `) are mood's too; see diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 1c585fa2d..ce08c12f3 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -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 = { }, }, }, + "/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"], diff --git a/src/lib/queries/__tests__/use-documents-layout.test.ts b/src/lib/queries/__tests__/use-documents-layout.test.ts new file mode 100644 index 000000000..b7c3d01e5 --- /dev/null +++ b/src/lib/queries/__tests__/use-documents-layout.test.ts @@ -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((resolve) => { + settle = resolve; + }), + ); + + const run = runSetDocumentsLayout({ + patch: { view: "list" }, + queryClient, + t, + }); + expect(queryClient.getQueryData(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"); + }); +}); diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts new file mode 100644 index 000000000..e9602a5d7 --- /dev/null +++ b/src/lib/queries/use-documents-layout.ts @@ -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; + +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 { + const { patch, queryClient, t } = deps; + const key = queryKeys.documentsLayout(); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, { + ...(previous ?? DEFAULT_DOCUMENTS_LAYOUT), + ...patch, + }); + try { + const saved = await apiPut( + "/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; + setArrangement: (arrangement: DocumentsLayoutArrangement) => Promise; +} { + const queryClient = useQueryClient(); + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.documentsLayout(), + queryFn: () => apiGet("/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 }), + }; +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index c5c8e8dc9..a562672b7 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -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`). diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 50c666df3..23083ea68 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(),