diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bdae4fa6..cfecce59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,8 +76,10 @@ an earlier fold computed from part of a day. when each type last arrived, by which trigger (including the new `manual` for Sync all) and when it last brought a new value (migration 0384). -- **Documents layout.** Preview cards or a compact list, months stacked or - flowing, per account (migration 0388). +- **Documents layout.** Preview cards or a compact list, months flowing + side by side (the default) or stacked, per account (migration 0388). Only + a field the person picked is stored, so an untouched one follows the + default. - **Measurement maintenance.** An admin card and `POST /api/admin/maintenance/measurements` run `VACUUM (ANALYZE)` and a `REINDEX INDEX CONCURRENTLY` per index, largest first. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 323f63544..24679b910 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -71374,7 +71374,7 @@ components: - 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".' + month names inline. Default "flow"; an account that never chose keeps following the default.' required: - version - view diff --git a/src/app/api/documents/inbound/layout/__tests__/route.test.ts b/src/app/api/documents/inbound/layout/__tests__/route.test.ts index 8c267af61..41ca79b2b 100644 --- a/src/app/api/documents/inbound/layout/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/layout/__tests__/route.test.ts @@ -89,9 +89,27 @@ describe("GET /api/documents/inbound/layout", () => { const res = await callGet(); expect(res.status).toBe(200); expect((await res.json()).data).toEqual(DEFAULT_DOCUMENTS_LAYOUT); + expect(DEFAULT_DOCUMENTS_LAYOUT).toEqual({ + version: 1, + view: "cards", + arrangement: "flow", + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); + it("keeps a saved stacked arrangement although the default is flow", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { + version: 1, + view: "cards", + arrangement: "stacked", + }, + } as never); + + const res = await callGet(); + expect((await res.json()).data.arrangement).toBe("stacked"); + }); + 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" }, @@ -101,7 +119,7 @@ describe("GET /api/documents/inbound/layout", () => { expect((await res.json()).data).toEqual({ version: 1, view: "list", - arrangement: "stacked", + arrangement: "flow", }); }); @@ -146,6 +164,23 @@ describe("PUT /api/documents/inbound/layout", () => { }); }); + it("stores only the chosen field, so the untouched one keeps following the default", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: null, + } as never); + + const res = await put({ version: 1, view: "list" }); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "flow", + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { documentsLayoutJson: { version: 1, view: "list" } }, + }); + }); + 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); diff --git a/src/app/api/documents/inbound/layout/route.ts b/src/app/api/documents/inbound/layout/route.ts index 45d3e2b94..e81e5bca8 100644 --- a/src/app/api/documents/inbound/layout/route.ts +++ b/src/app/api/documents/inbound/layout/route.ts @@ -20,6 +20,7 @@ import { DOCUMENTS_LAYOUT_ARRANGEMENTS, DOCUMENTS_LAYOUT_VIEWS, resolveDocumentsLayout, + storedDocumentsLayoutChoices, type DocumentsLayout, } from "@/lib/documents/documents-layout"; import { annotate } from "@/lib/logging/context"; @@ -35,12 +36,12 @@ const layoutPutSchema = z }) .strict(); -async function readLayout(userId: string): Promise { +async function readStoredLayout(userId: string): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, select: { documentsLayoutJson: true }, }); - return resolveDocumentsLayout(row?.documentsLayoutJson); + return row?.documentsLayoutJson; } export const GET = apiHandler(async () => { @@ -51,7 +52,7 @@ export const GET = apiHandler(async () => { const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const layout = await readLayout(user.id); + const layout = resolveDocumentsLayout(await readStoredLayout(user.id)); annotate({ action: { name: "documents.layout.read" }, meta: { view: layout.view, arrangement: layout.arrangement }, @@ -78,17 +79,20 @@ export const PUT = apiHandler(async (request: Request) => { } // 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, + // left out. Only fields the user has chosen are written, so a field never + // picked keeps following the default (which can change between releases). + const choices = { + ...storedDocumentsLayoutChoices(await readStoredLayout(user.id)), + ...(parsed.data.view ? { view: parsed.data.view } : {}), + ...(parsed.data.arrangement + ? { arrangement: parsed.data.arrangement } + : {}), }; + const next: DocumentsLayout = resolveDocumentsLayout(choices); await prisma.user.update({ where: { id: user.id }, - data: { documentsLayoutJson: toJson(next) }, + data: { documentsLayoutJson: toJson({ version: 1, ...choices }) }, }); annotate({ diff --git a/src/components/documents/document-timeline.tsx b/src/components/documents/document-timeline.tsx index d8eb507b7..a1b75597a 100644 --- a/src/components/documents/document-timeline.tsx +++ b/src/components/documents/document-timeline.tsx @@ -31,9 +31,10 @@ 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, +import { + DEFAULT_DOCUMENTS_LAYOUT, + type DocumentsLayoutArrangement, + type DocumentsLayoutView, } from "@/lib/documents/documents-layout"; import { columnsForWidth, @@ -65,8 +66,8 @@ export function DocumentTimeline({ highlightId, onPrefetch, timezone, - view = "cards", - arrangement = "stacked", + view = DEFAULT_DOCUMENTS_LAYOUT.view, + arrangement = DEFAULT_DOCUMENTS_LAYOUT.arrangement, }: { /** Preview tiles or compact rows (the reader's vault presentation). */ view?: DocumentsLayoutView; diff --git a/src/lib/documents/documents-layout.ts b/src/lib/documents/documents-layout.ts index dc796ecd9..fcef2266d 100644 --- a/src/lib/documents/documents-layout.ts +++ b/src/lib/documents/documents-layout.ts @@ -10,9 +10,10 @@ * 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 + * Null / missing column = the defaults below (cards, flowing months). The GET + * endpoint never lazy-writes, and the PUT stores only the fields the user has + * actually chosen, so a default that changes later reaches every account that + * never picked that field, while a saved choice stays. Mirrors * `medication-list-layout.ts`: tolerant resolver on read, normaliser on write, * preserve-when-absent PUT (see the route). */ @@ -33,7 +34,7 @@ export interface DocumentsLayout { export const DEFAULT_DOCUMENTS_LAYOUT: DocumentsLayout = { version: 1, view: "cards", - arrangement: "stacked", + arrangement: "flow", }; function isView(value: unknown): value is DocumentsLayoutView { @@ -68,3 +69,22 @@ export function resolveDocumentsLayout(raw: unknown): DocumentsLayout { : DEFAULT_DOCUMENTS_LAYOUT.arrangement, }; } + +/** + * The fields a stored blob explicitly carries, each validated. Unknown or + * malformed values drop out, so the default fills them on read. The PUT + * merges into this rather than into the resolved layout, which keeps an + * untouched field following the default instead of freezing today's value. + */ +export function storedDocumentsLayoutChoices( + raw: unknown, +): Partial> { + if (raw === null || raw === undefined || typeof raw !== "object") return {}; + const blob = raw as { view?: unknown; arrangement?: unknown }; + return { + ...(isView(blob.view) ? { view: blob.view } : {}), + ...(isArrangement(blob.arrangement) + ? { arrangement: blob.arrangement } + : {}), + }; +} diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index ce08c12f3..419876ce4 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -62,7 +62,7 @@ const documentsLayoutSchema = z 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".', + '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with month names inline. Default "flow"; an account that never chose keeps following the default.', ), }) .meta({