feat(documents): run the vault's months side by side by default

An account that never picked an arrangement now sees the months flowing
across the width. The layout PUT stores only the fields the person chose,
so an untouched field keeps following the default while a saved choice
stays.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 08:50:20 +02:00
1 parent 88836452eb
commit 7340cd3ab7
7 files changed
+86 -24

No files matched your search

+4 -2
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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);
+14 -10
View File
@@ -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<DocumentsLayout> {
async function readStoredLayout(userId: string): Promise<unknown> {
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({
@@ -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;
+24 -4
View File
@@ -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<Pick<DocumentsLayout, "view" | "arrangement">> {
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 }
: {}),
};
}
+1 -1
View File
@@ -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({