feat(settings): keep list order and preview opt-ins on the server

Add per-user columns for the labs, illness and checkups list presentation
and for preview-feature opt-ins, each with a GET plus a preserve-when-absent
write under /api/settings. The list hook now reads and writes the server
value and carries a browser-stored order up once when the server holds
none, so an existing arrangement follows the person to every device instead
of resetting.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:11:23 +02:00
1 parent 45da8ae971
commit 93198017d6
19 files changed
+1614 -208

No files matched your search

+256
View File
@@ -14488,6 +14488,81 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/settings/module-list-layout:
get:
tags:
- Meta
summary: Get the list presentation of labs, illness and checkups
description: Every list resolved, defaults filled in, with `saved` per list. Never writes a row.
responses:
"200":
description: The resolved presentation of every list.
content:
application/json:
schema:
$ref: "#/components/schemas/ModuleListLayoutEnvelope"
"401": *a1
"422": *a4
"429": *a5
put:
tags:
- Meta
summary: Change the presentation of one list
description: "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays
as stored. Returns every list resolved."
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ModuleListLayoutPutBody"
responses:
"200":
description: The presentation as stored.
content:
application/json:
schema:
$ref: "#/components/schemas/ModuleListLayoutSavedEnvelope"
"401": *a1
"422": *a4
"429": *a5
/api/settings/preview-features:
get:
tags:
- Meta
summary: Get the preview-feature opt-ins
description: Every flag, off unless the caller switched it on.
responses:
"200":
description: The opt-ins.
content:
application/json:
schema:
$ref: "#/components/schemas/PreviewFeaturesEnvelope"
"401": *a1
"422": *a4
"429": *a5
patch:
tags:
- Meta
summary: Switch preview features on or off
description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PreviewFeaturesPatchBody"
responses:
"200":
description: The opt-ins as stored.
content:
application/json:
schema:
$ref: "#/components/schemas/PreviewFeaturesSavedEnvelope"
"401": *a1
"422": *a4
"429": *a5
/api/settings/privacy-summary:
get:
tags:
@@ -31354,6 +31429,48 @@ components:
required:
- confirm
description: Typed confirmation for erasing the record. No undo.
ModuleListLayoutPutBody:
type: object
properties:
version:
type: number
const: 1
list:
type: string
enum:
- labs
- illness
- vorsorge
view:
type: string
enum:
- cards
- list
order:
maxItems: 500
type: array
items:
type: string
minLength: 1
maxLength: 64
sortDir:
type: string
enum:
- recentDesc
- recentAsc
- alphaAsc
- alphaDesc
- manual
required:
- version
- list
additionalProperties: false
PreviewFeaturesPatchBody:
type: object
properties:
topics:
type: boolean
additionalProperties: false
ConsentPostBody:
type: object
properties:
@@ -38796,6 +38913,8 @@ components:
medicationListLayoutJson: {}
moodTagLayoutJson: {}
documentsLayoutJson: {}
moduleListLayoutJson: {}
previewFeaturesJson: {}
reportSelectionJson: {}
globalExcludedInjectionSites:
type: array
@@ -69269,6 +69388,143 @@ components:
- deletedRows
- models
additionalProperties: false
ModuleListLayoutEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/ModuleListLayout"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ModuleListLayout:
type: object
properties:
version:
type: number
const: 1
lists:
type: object
properties:
labs:
$ref: "#/components/schemas/ModuleListPrefs"
illness:
$ref: "#/components/schemas/ModuleListPrefs"
vorsorge:
$ref: "#/components/schemas/ModuleListPrefs"
required:
- labs
- illness
- vorsorge
additionalProperties: false
required:
- version
- lists
additionalProperties: false
description: Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render
a list ignores it.
ModuleListPrefs:
type: object
properties:
view:
type: string
enum:
- cards
- list
description: '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.'
order:
type: array
items:
type: string
description: Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list
follow in the server order.
sortDir:
type: string
enum:
- recentDesc
- recentAsc
- alphaAsc
- alphaDesc
- manual
description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.
saved:
type: boolean
description: True when the server holds a choice for this list; false means every field is a default.
required:
- view
- order
- sortDir
- saved
additionalProperties: false
ModuleListLayoutSavedEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/ModuleListLayout"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
PreviewFeaturesEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/PreviewFeatures"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
PreviewFeatures:
type: object
properties:
topics:
type: boolean
description: Topic overview pages above the single-metric Insights pages. Off by default.
required:
- topics
additionalProperties: false
description: Per-user opt-in to preview features, one boolean per flag, every flag off by default.
PreviewFeaturesSavedEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/PreviewFeatures"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
PrivacySummaryEnvelope:
type: object
properties:
@@ -0,0 +1,17 @@
-- Two per-user preference columns for Settings.
--
-- module_list_layout_json: the card/list view, manual order and labs sort for
-- the labs, illness and checkups lists. These lived in the browser until now,
-- so they did not follow a person to another device. The client copies a value
-- it still holds in the browser up once, when the server has none for that list.
--
-- preview_features_json: the per-user opt-in to preview features, one boolean
-- per flag, all off by default.
--
-- Nullable, no default, no backfill: null reads as the defaults.
--
-- Reversibility: dropping the columns returns every list to its default
-- presentation and switches every preview feature off.
ALTER TABLE "users" ADD COLUMN "module_list_layout_json" JSONB;
ALTER TABLE "users" ADD COLUMN "preview_features_json" JSONB;
+13
View File
@@ -364,6 +364,19 @@ model User {
// Shape: { "groupOrder": ["<categoryKey>", ...],
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Per-user list presentation for the labs, illness and checkups lists:
// card/list view, manual order, and (labs only) the sort. One blob keyed by
// list, resolved field by field on read (`src/lib/settings/module-list-layout.ts`),
// preserve-when-absent PUT on `/api/settings/module-list-layout`. Null = the
// defaults. Earlier releases kept the same choices in the browser; the client
// copies a stored browser value up once when the server has none for that list.
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
moduleListLayoutJson Json? @map("module_list_layout_json")
/// Per-user opt-in to preview features, one boolean per flag, all off by
/// default (`src/lib/settings/preview-features.ts`). Read and written by
/// Settings > System through `/api/settings/preview-features`.
/// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will.
previewFeaturesJson Json? @map("preview_features_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
@@ -0,0 +1,94 @@
/**
* GET / PUT the list presentation for the labs, illness and checkups lists.
*
* GET returns every list resolved, defaults filled in, with `saved` per list
* so a client can tell a stored choice from a default. PUT changes one list
* with preserve-when-absent semantics: a body carrying only `view` keeps the
* stored order and sort, and vice versa, the contract `/api/documents/inbound/layout`
* keeps for its fields. The blob lives on its own `User` column
* (`module_list_layout_json`).
*
* Both halves are the caller's own presentation. Inside somebody else's
* record the ids in a saved order simply do not match and the list keeps the
* server order, so no record gate is needed here.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
MODULE_LIST_KEYS,
resolveModuleListLayout,
storedModuleListLayout,
} from "@/lib/settings/module-list-layout";
import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs";
export const dynamic = "force-dynamic";
async function readStored(userId: string): Promise<unknown> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { moduleListLayoutJson: true },
});
return row?.moduleListLayoutJson;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const layout = resolveModuleListLayout(await readStored(user.id));
annotate({
action: { name: "settings.module-list-layout.read" },
meta: {
saved: MODULE_LIST_KEYS.filter((list) => layout.lists[list].saved),
},
});
return apiSuccess(layout);
});
export const PUT = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
// 500 ids of up to 64 characters plus the envelope.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 48 * 1024,
});
if (jsonError) return jsonError;
const parsed = moduleListLayoutPutSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "settings.module-list-layout.validation-failed" },
});
return returnAllZodIssues(parsed.error, 422);
}
const { list, view, order, sortDir } = parsed.data;
// Preserve-when-absent: one stored read fills whichever field the body
// left out, and every other list stays as stored. Only fields a person has
// chosen are written, so a field never picked keeps following the default.
const stored = storedModuleListLayout(await readStored(user.id));
const nextChoices = {
...stored[list],
...(view ? { view } : {}),
...(order ? { order: [...new Set(order)] } : {}),
...(sortDir ? { sortDir } : {}),
};
const nextStored = { ...stored, [list]: nextChoices };
await prisma.user.update({
where: { id: user.id },
data: { moduleListLayoutJson: toJson({ version: 1, ...nextStored }) },
});
const layout = resolveModuleListLayout(nextStored);
annotate({
action: { name: "settings.module-list-layout.update" },
meta: {
list,
view: layout.lists[list].view,
sortDir: layout.lists[list].sortDir,
orderLength: layout.lists[list].order.length,
},
});
return apiSuccess(layout);
});
@@ -0,0 +1,75 @@
/**
* GET / PATCH the caller's preview-feature opt-ins.
*
* GET answers every flag, off unless the caller switched it on. PATCH sets
* the flags the body names and keeps the rest; unknown flags are refused
* with the multi-issue 422 so a typo cannot silently do nothing. The blob
* lives on its own `User` column (`preview_features_json`).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
PREVIEW_FEATURE_KEYS,
resolvePreviewFeatures,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs";
export const dynamic = "force-dynamic";
async function readStored(userId: string): Promise<unknown> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { previewFeaturesJson: true },
});
return row?.previewFeaturesJson;
}
function enabledFlags(features: PreviewFeatures): string[] {
return PREVIEW_FEATURE_KEYS.filter((key) => features[key]);
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const features = resolvePreviewFeatures(await readStored(user.id));
annotate({
action: { name: "settings.preview-features.read" },
meta: { enabled: enabledFlags(features) },
});
return apiSuccess(features);
});
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = previewFeaturesPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "settings.preview-features.validation-failed" },
});
return returnAllZodIssues(parsed.error, 422);
}
const current = resolvePreviewFeatures(await readStored(user.id));
const next: PreviewFeatures = {
topics: parsed.data.topics ?? current.topics,
};
await prisma.user.update({
where: { id: user.id },
data: { previewFeaturesJson: toJson(next) },
});
annotate({
action: { name: "settings.preview-features.update" },
meta: { enabled: enabledFlags(next) },
});
return apiSuccess(next);
});
+208 -66
View File
@@ -1,82 +1,224 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { applyOrder, parseModuleListPrefs } from "@/lib/module-list-prefs";
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiPut: vi.fn(),
}));
vi.mock("@/components/outcome/outcome-toast", () => ({
toastWrittenOutcome: vi.fn(),
}));
vi.mock("@/lib/errors/error-toast", () => ({
showErrorToast: vi.fn(),
}));
describe("module-list-prefs", () => {
describe("parseModuleListPrefs", () => {
it("returns defaults for null / malformed input", () => {
expect(parseModuleListPrefs(null)).toEqual({
view: "cards",
order: [],
sortDir: "recentDesc",
});
expect(parseModuleListPrefs("{not json")).toEqual({
view: "cards",
order: [],
sortDir: "recentDesc",
});
});
import { apiPut } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
applyOrder,
carryOverLegacyModuleListPrefs,
legacyModuleListPatch,
legacyModuleListStorageKey,
parseModuleListPrefs,
resetModuleListCarryOverForTests,
runSetModuleListPrefs,
} from "@/lib/module-list-prefs";
import {
moduleListDefaults,
resolveModuleListLayout,
} from "@/lib/settings/module-list-layout";
import { queryKeys } from "@/lib/query-keys";
it("coerces unknown view / sortDir to defaults but keeps a valid order", () => {
const parsed = parseModuleListPrefs(
JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
);
expect(parsed).toEqual({
view: "cards",
sortDir: "recentDesc",
order: ["a", "b"],
});
});
const store = new Map<string, string>();
const fakeStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
};
it("round-trips valid values", () => {
const parsed = parseModuleListPrefs(
JSON.stringify({ view: "list", sortDir: "manual", order: ["x"] }),
);
expect(parsed).toEqual({
view: "list",
sortDir: "manual",
order: ["x"],
});
});
beforeEach(() => {
vi.resetAllMocks();
store.clear();
resetModuleListCarryOverForTests();
vi.stubGlobal("window", { localStorage: fakeStorage });
});
it("accepts the alphabetical sort directions (#43)", () => {
for (const sortDir of ["alphaAsc", "alphaDesc"]) {
const parsed = parseModuleListPrefs(JSON.stringify({ sortDir }));
expect(parsed.sortDir).toBe(sortDir);
}
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("applies a per-call default when no blob is stored (Labs list view, #40)", () => {
const labsDefaults = {
view: "list" as const,
order: [],
sortDir: "recentDesc" as const,
};
expect(parseModuleListPrefs(null, labsDefaults)).toEqual(labsDefaults);
// A stored view still wins over the default.
expect(
parseModuleListPrefs(JSON.stringify({ view: "cards" }), labsDefaults)
.view,
).toBe("cards");
});
describe("parseModuleListPrefs", () => {
const defaults = moduleListDefaults("illness");
it("returns defaults for null / malformed input", () => {
expect(parseModuleListPrefs(null, defaults)).toEqual(defaults);
expect(parseModuleListPrefs("{not json", defaults)).toEqual(defaults);
});
describe("applyOrder", () => {
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
const idOf = (i: { id: string }) => i.id;
it("coerces unknown view / sortDir to defaults but keeps a valid order", () => {
expect(
parseModuleListPrefs(
JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
defaults,
),
).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] });
});
it("returns the input order when no order is persisted", () => {
expect(applyOrder(items, [], idOf)).toEqual(items);
it("applies the labs default (list view, #40) when nothing is stored", () => {
const labs = moduleListDefaults("labs");
expect(parseModuleListPrefs(null, labs).view).toBe("list");
expect(
parseModuleListPrefs(JSON.stringify({ view: "cards" }), labs).view,
).toBe("cards");
});
});
describe("legacyModuleListPatch", () => {
it("carries only what differs from the default", () => {
expect(
legacyModuleListPatch(
"labs",
JSON.stringify({ view: "list", order: [], sortDir: "manual" }),
),
).toEqual({ sortDir: "manual" });
expect(
legacyModuleListPatch(
"illness",
JSON.stringify({ view: "list", order: ["e2", "e1"] }),
),
).toEqual({ view: "list", order: ["e2", "e1"] });
expect(legacyModuleListPatch("vorsorge", null)).toEqual({});
});
});
describe("carryOverLegacyModuleListPrefs", () => {
it("sends a browser-stored order up once and removes the browser copy after the server confirmed it", async () => {
store.set(
legacyModuleListStorageKey("illness"),
JSON.stringify({ view: "cards", order: ["e2", "e1"] }),
);
const saved = resolveModuleListLayout({ illness: { order: ["e2", "e1"] } });
vi.mocked(apiPut).mockResolvedValue(saved);
const queryClient = new QueryClient();
await carryOverLegacyModuleListPrefs({
list: "illness",
layout: resolveModuleListLayout(null),
queryClient,
});
expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
version: 1,
list: "illness",
order: ["e2", "e1"],
});
expect(store.has(legacyModuleListStorageKey("illness"))).toBe(false);
expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
saved,
);
// A second mount in the same page load does not send it again.
await carryOverLegacyModuleListPrefs({
list: "illness",
layout: saved,
queryClient,
});
expect(apiPut).toHaveBeenCalledTimes(1);
});
it("keeps the browser copy when the write fails, so the order is never lost", async () => {
store.set(
legacyModuleListStorageKey("labs"),
JSON.stringify({ order: ["m1"], sortDir: "manual" }),
);
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
await carryOverLegacyModuleListPrefs({
list: "labs",
layout: resolveModuleListLayout(null),
queryClient: new QueryClient(),
});
expect(store.has(legacyModuleListStorageKey("labs"))).toBe(true);
});
it("leaves a list the server already holds alone and drops the stale browser copy", async () => {
store.set(
legacyModuleListStorageKey("vorsorge"),
JSON.stringify({ order: ["r9"] }),
);
await carryOverLegacyModuleListPrefs({
list: "vorsorge",
layout: resolveModuleListLayout({ vorsorge: { order: ["r1"] } }),
queryClient: new QueryClient(),
});
expect(apiPut).not.toHaveBeenCalled();
expect(store.has(legacyModuleListStorageKey("vorsorge"))).toBe(false);
});
});
describe("runSetModuleListPrefs", () => {
const t = (key: string) => key;
it("places ordered ids first, then the server-default tail", () => {
const result = applyOrder(items, ["c", "a"], idOf);
expect(result.map(idOf)).toEqual(["c", "a", "b"]);
it("applies the change at once and confirms a view change with a toast", async () => {
const queryClient = new QueryClient();
const saved = resolveModuleListLayout({ labs: { view: "cards" } });
vi.mocked(apiPut).mockResolvedValue(saved);
await runSetModuleListPrefs({
list: "labs",
patch: { view: "cards" },
queryClient,
t,
});
expect(toastWrittenOutcome).toHaveBeenCalledWith(
"success",
"settings.saved",
);
expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
saved,
);
});
it("ignores ids in the order that are absent from the data", () => {
const result = applyOrder(items, ["z", "b"], idOf);
expect(result.map(idOf)).toEqual(["b", "a", "c"]);
it("rolls back and says so when the write fails", async () => {
const queryClient = new QueryClient();
const before = resolveModuleListLayout(null);
queryClient.setQueryData(queryKeys.moduleListLayout(), before);
vi.mocked(apiPut).mockRejectedValue(new Error("nope"));
await runSetModuleListPrefs({
list: "illness",
patch: { order: ["x"] },
queryClient,
t,
});
expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
before,
);
expect(showErrorToast).toHaveBeenCalled();
});
});
describe("applyOrder", () => {
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
const idOf = (i: { id: string }) => i.id;
it("keeps the input order with no saved order", () => {
expect(applyOrder(items, [], idOf)).toEqual(items);
});
it("puts ordered ids first and the rest after in input order", () => {
expect(applyOrder(items, ["c", "a"], idOf).map(idOf)).toEqual([
"c",
"a",
"b",
]);
});
it("drops unknown ids", () => {
expect(applyOrder(items, ["z", "b"], idOf).map(idOf)).toEqual([
"b",
"a",
"c",
]);
});
});
+3
View File
@@ -484,6 +484,7 @@ export const USER_RESET = {
medicationListLayoutJson: Prisma.DbNull,
moodTagLayoutJson: Prisma.DbNull,
documentsLayoutJson: Prisma.DbNull,
moduleListLayoutJson: Prisma.DbNull,
reportSelectionJson: Prisma.DbNull,
modulePreferencesJson: Prisma.DbNull,
healthScoreConfigJson: Prisma.DbNull,
@@ -510,6 +511,8 @@ export const USER_KEPT_FIELDS: Readonly<Record<string, string>> = {
oidcIssuer: "a sign-in credential — the external identity binding",
oidcSub: "a sign-in credential — the external identity binding",
createdAt: "when the account was created; the account is preserved",
previewFeaturesJson:
"which preview features the person opted into — how the interface is shown, not record content",
updatedAt: "row bookkeeping maintained by Prisma",
managedProfileAt:
"whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.",
@@ -503,6 +503,8 @@ const ACCOUNT_SETTING_CODECS: Readonly<
medicationListLayoutJson: json("medicationListLayoutJson"),
moodTagLayoutJson: json("moodTagLayoutJson"),
documentsLayoutJson: json("documentsLayoutJson"),
moduleListLayoutJson: json("moduleListLayoutJson"),
previewFeaturesJson: json("previewFeaturesJson"),
reportSelectionJson: json("reportSelectionJson"),
globalExcludedInjectionSites: excludedSites,
healthKitConfigJson: json("healthKitConfigJson"),
+2
View File
@@ -1010,6 +1010,8 @@ export const USER_COLUMN_BACKUP_CLASS = {
medicationListLayoutJson: "SETTING",
moodTagLayoutJson: "SETTING",
documentsLayoutJson: "SETTING",
moduleListLayoutJson: "SETTING",
previewFeaturesJson: "SETTING",
reportSelectionJson: "SETTING",
globalExcludedInjectionSites: "SETTING",
healthKitConfigJson: "SETTING",
+217 -142
View File
@@ -1,109 +1,103 @@
"use client";
/**
* v1.18.6 — client-side per-module list preferences for the
* three secondary modules (Vorsorge, Illness, Labs).
* Per-list presentation for the labs, illness and checkups lists: card or
* list view, a manual order, and (labs only) the sort.
*
* These modules do not carry a server-side list-layout column the way
* Medications does (`/api/medications/layout`). Rather than mint three new
* Prisma columns + routes — which a parallel backend phase owns — the
* reorder + view choice live in `localStorage`, keyed per module. The choice
* is presentational only: it reorders / reshapes what the page already
* fetched and never feeds a server read, so a missing or stale blob degrades
* to the server's own default order.
* The choices live on the server (`GET/PUT /api/settings/module-list-layout`,
* `User.moduleListLayoutJson`) so they follow a person to every device. Until
* v1.44 they lived in this browser's `localStorage`, one key per list. Those
* values are carried over, never reset: when the server holds nothing for a
* list and this browser still has a value, the value is sent up once and the
* browser copy is removed after the server confirmed it. While the first read
* is in flight the browser copy (or the default) paints, so a person who
* arranged a list does not see it jump back to the default order.
*
* Shape per module (one localStorage key each):
* { view: "cards" | "list", order: string[] }
* `order` is an id allow-list; ids absent from the live data are ignored and
* ids present in the data but absent from `order` sort after the ordered
* block (the server default order is preserved for the tail).
* The choice is presentational only: it reorders or reshapes what the page
* already fetched, so a missing or stale value degrades to the server order.
*/
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo } from "react";
import {
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
export const MODULE_LIST_VIEWS = ["cards", "list"] as const;
export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number];
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useMounted } from "@/hooks/use-mounted";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
isModuleListView,
isModuleSortDir,
moduleListDefaults,
normaliseModuleListOrder,
resolveModuleListLayout,
type ModuleListKey,
type ModuleListLayout,
type ModuleListPrefs,
type ModuleListView,
type ModuleSortDir,
} from "@/lib/settings/module-list-layout";
export type ModuleListKey = "vorsorge" | "illness" | "labs";
export type {
ModuleListKey,
ModuleListPrefs,
ModuleListView,
ModuleSortDir,
} from "@/lib/settings/module-list-layout";
const ENDPOINT = "/api/settings/module-list-layout";
/** The browser key earlier releases stored a list's choices under. */
export function legacyModuleListStorageKey(list: ModuleListKey): string {
return `healthlog:module-list-prefs:${list}`;
}
/**
* Sort choice for the Labs surface (MOD-04): order biomarker groups by the
* recency of their most-recent reading, alphabetically by analyte name
* (#43), or `manual`, which defers to the persisted `order` array.
* Parse a browser-stored blob from before the server column. Anything
* malformed collapses onto the defaults field by field.
*/
export const MODULE_SORT_DIRS = [
"recentDesc",
"recentAsc",
"alphaAsc",
"alphaDesc",
"manual",
] as const;
export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number];
export interface ModuleListPrefs {
view: ModuleListView;
order: string[];
/** Only consumed by the Labs surface; defaults to most-recent-first. */
sortDir: ModuleSortDir;
}
const DEFAULT_PREFS: ModuleListPrefs = {
view: "cards",
order: [],
sortDir: "recentDesc",
};
/**
* Per-module default overrides applied when no preference blob is stored yet.
* Labs defaults to the compact list view (#40) so the dense reading table is
* the first thing the user sees; Vorsorge and Illness keep the card grid.
*/
const MODULE_DEFAULTS: Partial<
Record<ModuleListKey, Partial<ModuleListPrefs>>
> = {
labs: { view: "list" },
};
function defaultsFor(module: ModuleListKey): ModuleListPrefs {
return { ...DEFAULT_PREFS, ...MODULE_DEFAULTS[module] };
}
function storageKey(module: ModuleListKey): string {
return `healthlog:module-list-prefs:${module}`;
}
function isView(value: unknown): value is ModuleListView {
return (
typeof value === "string" &&
(MODULE_LIST_VIEWS as readonly string[]).includes(value)
);
}
function isSortDir(value: unknown): value is ModuleSortDir {
return (
typeof value === "string" &&
(MODULE_SORT_DIRS as readonly string[]).includes(value)
);
}
export function parseModuleListPrefs(
raw: string | null,
defaults: ModuleListPrefs = DEFAULT_PREFS,
defaults: ModuleListPrefs,
): ModuleListPrefs {
if (!raw) return defaults;
try {
const blob = JSON.parse(raw) as Record<string, unknown>;
const view = isView(blob.view) ? blob.view : defaults.view;
const order = Array.isArray(blob.order)
? blob.order.filter((v): v is string => typeof v === "string")
: [];
const sortDir = isSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir;
return { view, order, sortDir };
return {
view: isModuleListView(blob.view) ? blob.view : defaults.view,
order: normaliseModuleListOrder(blob.order) ?? [],
sortDir: isModuleSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir,
};
} catch {
return defaults;
}
}
/**
* The fields of a browser-stored value worth carrying over: the ones that
* differ from the default. The old blob always held all three fields, so a
* field equal to the default cannot be told apart from one never chosen and
* is left to follow the default.
*/
export function legacyModuleListPatch(
list: ModuleListKey,
raw: string | null,
): Partial<ModuleListPrefs> {
if (!raw) return {};
const defaults = moduleListDefaults(list);
const legacy = parseModuleListPrefs(raw, defaults);
return {
...(legacy.view !== defaults.view ? { view: legacy.view } : {}),
...(legacy.order.length > 0 ? { order: legacy.order } : {}),
...(legacy.sortDir !== defaults.sortDir ? { sortDir: legacy.sortDir } : {}),
};
}
/**
* Apply a persisted id order to a live list. Items whose id appears in
* `order` come first in that order; everything else keeps the input order
@@ -127,84 +121,165 @@ export function applyOrder<T>(
return [...ordered, ...rest];
}
// A module-scoped event so every mounted hook for the same module repaints
// when one writer flushes a change (the settings page + the module page can
// both be alive across a client navigation without a full reload).
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
function readLegacy(list: ModuleListKey): string | null {
try {
return window.localStorage.getItem(legacyModuleListStorageKey(list));
} catch {
return null;
}
}
function removeLegacy(list: ModuleListKey): void {
try {
window.localStorage.removeItem(legacyModuleListStorageKey(list));
} catch {
// Storage unavailable: nothing to remove.
}
}
/** Lists already carried over (or checked) in this page load. */
const carriedOver = new Set<ModuleListKey>();
/**
* Carry a browser-stored value up to the server, once, when the server holds
* nothing for the list. A failed write leaves the browser copy in place and
* tries again on the next mount; a list the server already holds keeps the
* server value and drops the stale browser copy.
*/
export async function carryOverLegacyModuleListPrefs(deps: {
list: ModuleListKey;
layout: ModuleListLayout;
queryClient: QueryClient;
}): Promise<void> {
const { list, layout, queryClient } = deps;
if (typeof window === "undefined" || carriedOver.has(list)) return;
carriedOver.add(list);
const raw = readLegacy(list);
if (raw === null) return;
if (layout.lists[list].saved) {
removeLegacy(list);
return;
}
const patch = legacyModuleListPatch(list, raw);
if (Object.keys(patch).length === 0) {
removeLegacy(list);
return;
}
try {
const saved = await apiPut<ModuleListLayout>(ENDPOINT, {
version: 1,
list,
...patch,
});
if (saved) queryClient.setQueryData(queryKeys.moduleListLayout(), saved);
removeLegacy(list);
} catch {
carriedOver.delete(list);
}
}
/** Forget which lists were carried over. Tests only. */
export function resetModuleListCarryOverForTests(): void {
carriedOver.clear();
}
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* Apply one field optimistically (the control has to feel instant), then
* persist it. On failure the cache rolls back and a toast says so; a view or
* sort change confirms with a toast, an order change is its own feedback.
*/
export async function runSetModuleListPrefs(deps: {
list: ModuleListKey;
patch: Partial<ModuleListPrefs>;
queryClient: QueryClient;
t: Translator;
}): Promise<void> {
const { list, patch, queryClient, t } = deps;
const key = queryKeys.moduleListLayout();
if (queryClient.isFetching({ queryKey: key }) > 0) {
await queryClient.cancelQueries({ queryKey: key });
}
const previous = queryClient.getQueryData<ModuleListLayout>(key);
const base = previous ?? resolveModuleListLayout(null);
queryClient.setQueryData<ModuleListLayout>(key, {
...base,
lists: {
...base.lists,
[list]: { ...base.lists[list], ...patch, saved: true },
},
});
try {
const saved = await apiPut<ModuleListLayout>(ENDPOINT, {
version: 1,
list,
...patch,
});
if (saved) queryClient.setQueryData(key, saved);
if (patch.view !== undefined || patch.sortDir !== undefined) {
toastWrittenOutcome("success", t("settings.saved"));
}
} catch (failure) {
queryClient.setQueryData(key, previous);
showErrorToast(t("settings.savingError"), { cause: failure });
}
}
/**
* Read + write the per-module list preferences. SSR-safe via
* `useSyncExternalStore` with a null server snapshot (parsed to defaults).
* Read + write one list's presentation. The server value once it is read;
* before that, the browser copy from an earlier release, or the default.
*/
export function useModuleListPrefs(module: ModuleListKey): {
export function useModuleListPrefs(list: ModuleListKey): {
prefs: ModuleListPrefs;
setView: (view: ModuleListView) => void;
setOrder: (order: string[]) => void;
setSortDir: (sortDir: ModuleSortDir) => void;
} {
const key = storageKey(module);
const defaults = useMemo(() => defaultsFor(module), [module]);
const queryClient = useQueryClient();
const { t } = useTranslations();
const subscribe = useCallback(
(onChange: () => void) => {
listeners.add(onChange);
const onStorage = (e: StorageEvent) => {
if (e.key === null || e.key === key) onChange();
};
if (typeof window !== "undefined") {
window.addEventListener("storage", onStorage);
}
return () => {
listeners.delete(onChange);
if (typeof window !== "undefined") {
window.removeEventListener("storage", onStorage);
}
};
},
[key],
);
const { data } = useQuery({
queryKey: queryKeys.moduleListLayout(),
queryFn: () => apiGet<ModuleListLayout>(ENDPOINT),
staleTime: 5 * 60 * 1000,
});
const getSnapshot = useCallback(() => {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(key);
}, [key]);
useEffect(() => {
if (!data) return;
void carryOverLegacyModuleListPrefs({ list, layout: data, queryClient });
}, [data, list, queryClient]);
const raw = useSyncExternalStore(subscribe, getSnapshot, () => null);
// `useSyncExternalStore` returns a stable `raw` string when storage hasn't
// changed, so keying the parse on it gives downstream `applyOrder`
// memoisation a stable `prefs` reference instead of a fresh object + a
// JSON.parse on every render.
const prefs = useMemo(
() => parseModuleListPrefs(raw, defaults),
[raw, defaults],
);
// The browser copy is read only after hydration, so the server render and
// the first client paint agree (React #418).
const mounted = useMounted();
const entry = data?.lists[list];
const prefs = useMemo<ModuleListPrefs>(() => {
if (entry) {
return { view: entry.view, order: entry.order, sortDir: entry.sortDir };
}
const defaults = moduleListDefaults(list);
if (!mounted) return defaults;
return parseModuleListPrefs(readLegacy(list), defaults);
}, [entry, list, mounted]);
const write = useCallback(
(next: ModuleListPrefs) => {
if (typeof window === "undefined") return;
window.localStorage.setItem(key, JSON.stringify(next));
emit();
},
[key],
(patch: Partial<ModuleListPrefs>) =>
void runSetModuleListPrefs({ list, patch, queryClient, t }),
[list, queryClient, t],
);
const setView = useCallback(
(view: ModuleListView) =>
write({ ...parseModuleListPrefs(getSnapshot(), defaults), view }),
[write, getSnapshot, defaults],
);
const setOrder = useCallback(
(order: string[]) =>
write({ ...parseModuleListPrefs(getSnapshot(), defaults), order }),
[write, getSnapshot, defaults],
(view: ModuleListView) => write({ view }),
[write],
);
const setOrder = useCallback((order: string[]) => write({ order }), [write]);
const setSortDir = useCallback(
(sortDir: ModuleSortDir) =>
write({ ...parseModuleListPrefs(getSnapshot(), defaults), sortDir }),
[write, getSnapshot, defaults],
(sortDir: ModuleSortDir) => write({ sortDir }),
[write],
);
return { prefs, setView, setOrder, setSortDir };
+165
View File
@@ -23,6 +23,68 @@ import {
stdResponses,
} from "./shared";
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
import {
MODULE_LIST_VIEWS,
MODULE_SORT_DIRS,
} from "@/lib/settings/module-list-layout";
import {
moduleListLayoutPutSchema,
previewFeaturesPatchSchema,
} from "@/lib/validations/settings-prefs";
const moduleListPrefsEntry = z
.object({
view: z
.enum(MODULE_LIST_VIEWS)
.describe(
'`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.',
),
order: z
.array(z.string())
.describe(
"Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list follow in the server order.",
),
sortDir: z
.enum(MODULE_SORT_DIRS)
.describe(
'Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.',
),
saved: z
.boolean()
.describe(
"True when the server holds a choice for this list; false means every field is a default.",
),
})
.meta({ id: "ModuleListPrefs" });
const moduleListLayoutSchema = z
.object({
version: z.literal(1),
lists: z.object({
labs: moduleListPrefsEntry,
illness: moduleListPrefsEntry,
vorsorge: moduleListPrefsEntry,
}),
})
.meta({
id: "ModuleListLayout",
description:
"Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
});
const previewFeaturesSchema = z
.object({
topics: z
.boolean()
.describe(
"Topic overview pages above the single-metric Insights pages. Off by default.",
),
})
.meta({
id: "PreviewFeatures",
description:
"Per-user opt-in to preview features, one boolean per flag, every flag off by default.",
});
// v1.16.11 — the one threshold read every dose-status consumer makes
// (cards, table, take-all-due derivation). `lateMinutes` /
@@ -856,6 +918,109 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/settings/module-list-layout": {
get: {
tags: ["Meta"],
summary: "Get the list presentation of labs, illness and checkups",
description:
"Every list resolved, defaults filled in, with `saved` per list. Never writes a row.",
responses: {
"200": {
description: "The resolved presentation of every list.",
content: {
"application/json": {
schema: dataEnvelope(
moduleListLayoutSchema,
"ModuleListLayoutEnvelope",
),
},
},
},
...stdResponses,
},
},
put: {
tags: ["Meta"],
summary: "Change the presentation of one list",
description:
"Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays as stored. Returns every list resolved.",
requestBody: {
required: true,
content: {
"application/json": {
schema: moduleListLayoutPutSchema.meta({
id: "ModuleListLayoutPutBody",
}),
},
},
},
responses: {
"200": {
description: "The presentation as stored.",
content: {
"application/json": {
schema: dataEnvelope(
moduleListLayoutSchema,
"ModuleListLayoutSavedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/settings/preview-features": {
get: {
tags: ["Meta"],
summary: "Get the preview-feature opt-ins",
description: "Every flag, off unless the caller switched it on.",
responses: {
"200": {
description: "The opt-ins.",
content: {
"application/json": {
schema: dataEnvelope(
previewFeaturesSchema,
"PreviewFeaturesEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Meta"],
summary: "Switch preview features on or off",
description:
"Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.",
requestBody: {
required: true,
content: {
"application/json": {
schema: previewFeaturesPatchSchema.meta({
id: "PreviewFeaturesPatchBody",
}),
},
},
},
responses: {
"200": {
description: "The opt-ins as stored.",
content: {
"application/json": {
schema: dataEnvelope(
previewFeaturesSchema,
"PreviewFeaturesSavedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/settings/privacy-summary": {
get: {
tags: ["Meta"],
+9
View File
@@ -38,6 +38,15 @@ export const settingsKeys = {
*/
modulesPrefs: () => ["settings", "modules"] as const,
/**
* The labs / illness / checkups list presentation behind
* `GET/PUT /api/settings/module-list-layout` (view, manual order, labs sort).
*/
moduleListLayout: () => ["settings", "module-list-layout"] as const,
/** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */
previewFeatures: () => ["settings", "preview-features"] as const,
/**
* v1.35.0 — which pillars count toward the Health Score, behind
* `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
MODULE_LIST_ORDER_MAX,
normaliseModuleListOrder,
resolveModuleListLayout,
storedModuleListLayout,
} from "../module-list-layout";
describe("resolveModuleListLayout", () => {
it("answers the defaults, nothing saved, for an empty column", () => {
const layout = resolveModuleListLayout(null);
expect(layout.lists.labs).toEqual({
view: "list",
order: [],
sortDir: "recentDesc",
saved: false,
});
expect(layout.lists.illness.view).toBe("cards");
expect(layout.lists.vorsorge.view).toBe("cards");
expect(layout.lists.illness.saved).toBe(false);
});
it("fills a partial list field by field and marks it saved", () => {
const layout = resolveModuleListLayout({
version: 1,
illness: { order: ["b", "a"] },
});
expect(layout.lists.illness).toEqual({
view: "cards",
order: ["b", "a"],
sortDir: "recentDesc",
saved: true,
});
expect(layout.lists.labs.saved).toBe(false);
});
it("drops malformed values instead of failing", () => {
const layout = resolveModuleListLayout({
labs: { view: "grid", sortDir: 3, order: "x" },
vorsorge: "nonsense",
});
expect(layout.lists.labs).toEqual({
view: "list",
order: [],
sortDir: "recentDesc",
saved: false,
});
expect(layout.lists.vorsorge.saved).toBe(false);
});
});
describe("normaliseModuleListOrder", () => {
it("keeps strings, drops duplicates, empties and over-long ids", () => {
expect(
normaliseModuleListOrder(["a", "a", "", 4, "b", "x".repeat(65)]),
).toEqual(["a", "b"]);
});
it("caps the length", () => {
const ids = Array.from({ length: MODULE_LIST_ORDER_MAX + 10 }, (_, i) =>
String(i),
);
expect(normaliseModuleListOrder(ids)).toHaveLength(MODULE_LIST_ORDER_MAX);
});
it("answers null for a non-array", () => {
expect(normaliseModuleListOrder("a")).toBeNull();
});
});
describe("storedModuleListLayout", () => {
it("lists only the lists that carry a choice", () => {
expect(
storedModuleListLayout({ labs: { sortDir: "manual" }, illness: {} }),
).toEqual({ labs: { sortDir: "manual" } });
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PREVIEW_FEATURES,
resolvePreviewFeatures,
} from "../preview-features";
describe("resolvePreviewFeatures", () => {
it("is all off by default", () => {
expect(DEFAULT_PREVIEW_FEATURES).toEqual({ topics: false });
expect(resolvePreviewFeatures(null)).toEqual({ topics: false });
});
it("reads a flag only on an explicit true", () => {
expect(resolvePreviewFeatures({ topics: true })).toEqual({ topics: true });
expect(resolvePreviewFeatures({ topics: "true" })).toEqual({
topics: false,
});
});
it("drops unknown keys", () => {
expect(resolvePreviewFeatures({ topics: true, retired: true })).toEqual({
topics: true,
});
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* List presentation for the labs, illness and checkups lists, persisted in
* `User.moduleListLayoutJson`.
*
* Three independent choices per list, all display-only:
*
* - `view`: the card grid or the compact list.
* - `order`: an id allow-list. Ids absent from the live data are ignored;
* ids present in the data but absent from `order` sort after the ordered
* block in the server's own order.
* - `sortDir`: only the labs list reads it (recency, alphabetical, or the
* manual `order`).
*
* Null / missing column = the defaults below. The GET never lazy-writes, and
* the PUT stores only the fields a person has actually chosen, so a default
* that changes later still reaches every list nobody arranged. Mirrors
* `documents-layout.ts`: tolerant resolver on read, preserve-when-absent PUT.
*
* `saved` tells the client whether the server holds a choice for a list. The
* same choices lived in the browser before this column existed; the client
* copies a browser value up once, only while `saved` is false, so an order a
* person built is carried over instead of reset.
*/
export const MODULE_LIST_KEYS = ["labs", "illness", "vorsorge"] as const;
export type ModuleListKey = (typeof MODULE_LIST_KEYS)[number];
export const MODULE_LIST_VIEWS = ["cards", "list"] as const;
export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number];
export const MODULE_SORT_DIRS = [
"recentDesc",
"recentAsc",
"alphaAsc",
"alphaDesc",
"manual",
] as const;
export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number];
/** Longest order a list keeps; a longer one is cut, never refused. */
export const MODULE_LIST_ORDER_MAX = 500;
/** Longest id an order entry may carry (cuid and uuid fit comfortably). */
export const MODULE_LIST_ID_MAX = 64;
export interface ModuleListPrefs {
view: ModuleListView;
order: string[];
sortDir: ModuleSortDir;
}
export interface ModuleListPrefsEntry extends ModuleListPrefs {
/** True when the server holds at least one choice for this list. */
saved: boolean;
}
export interface ModuleListLayout {
version: 1;
lists: Record<ModuleListKey, ModuleListPrefsEntry>;
}
const BASE_DEFAULTS: ModuleListPrefs = {
view: "cards",
order: [],
sortDir: "recentDesc",
};
/** Labs opens on the compact list, the dense reading table (#40). */
const LIST_DEFAULTS: Record<ModuleListKey, ModuleListPrefs> = {
labs: { ...BASE_DEFAULTS, view: "list" },
illness: { ...BASE_DEFAULTS },
vorsorge: { ...BASE_DEFAULTS },
};
export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs {
const defaults = LIST_DEFAULTS[list];
return { ...defaults, order: [...defaults.order] };
}
export function isModuleListKey(value: unknown): value is ModuleListKey {
return (
typeof value === "string" &&
(MODULE_LIST_KEYS as readonly string[]).includes(value)
);
}
export function isModuleListView(value: unknown): value is ModuleListView {
return (
typeof value === "string" &&
(MODULE_LIST_VIEWS as readonly string[]).includes(value)
);
}
export function isModuleSortDir(value: unknown): value is ModuleSortDir {
return (
typeof value === "string" &&
(MODULE_SORT_DIRS as readonly string[]).includes(value)
);
}
/** Keep string ids of a sane length, first occurrence wins, capped. */
export function normaliseModuleListOrder(raw: unknown): string[] | null {
if (!Array.isArray(raw)) return null;
const seen = new Set<string>();
const out: string[] = [];
for (const value of raw) {
if (typeof value !== "string") continue;
if (value.length === 0 || value.length > MODULE_LIST_ID_MAX) continue;
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
if (out.length >= MODULE_LIST_ORDER_MAX) break;
}
return out;
}
/**
* The fields a stored blob explicitly carries for one list, each validated.
* Unknown or malformed values drop out, so the default fills them on read.
*/
export function storedModuleListChoices(
raw: unknown,
): Partial<ModuleListPrefs> {
if (raw === null || raw === undefined || typeof raw !== "object") return {};
const blob = raw as { view?: unknown; order?: unknown; sortDir?: unknown };
const order = normaliseModuleListOrder(blob.order);
return {
...(isModuleListView(blob.view) ? { view: blob.view } : {}),
...(order !== null ? { order } : {}),
...(isModuleSortDir(blob.sortDir) ? { sortDir: blob.sortDir } : {}),
};
}
/** Every list's stored choices, keyed by list. */
export function storedModuleListLayout(
raw: unknown,
): Partial<Record<ModuleListKey, Partial<ModuleListPrefs>>> {
if (raw === null || raw === undefined || typeof raw !== "object") return {};
const blob = raw as Record<string, unknown>;
const out: Partial<Record<ModuleListKey, Partial<ModuleListPrefs>>> = {};
for (const list of MODULE_LIST_KEYS) {
const choices = storedModuleListChoices(blob[list]);
if (Object.keys(choices).length > 0) out[list] = choices;
}
return out;
}
/**
* Any malformed, partial or legacy blob collapses onto the defaults field by
* field, so a GET never fails on a stored row.
*/
export function resolveModuleListLayout(raw: unknown): ModuleListLayout {
const stored = storedModuleListLayout(raw);
const lists = {} as Record<ModuleListKey, ModuleListPrefsEntry>;
for (const list of MODULE_LIST_KEYS) {
const choices = stored[list];
lists[list] = {
...moduleListDefaults(list),
...choices,
saved: choices !== undefined,
};
}
return { version: 1, lists };
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Per-user opt-in to preview features, persisted in `User.previewFeaturesJson`.
*
* One boolean per flag, every flag off by default. A preview feature is
* something finished enough to try and not yet the default; switching one on
* adds it beside what is there and never replaces an existing page.
*
* Flags:
* - `topics`: topic overview pages above the single-metric Insights pages.
* Stored and shown in Settings > System; no surface reads it yet.
*
* Unknown keys in a stored blob are dropped on read, so a flag retired later
* disappears cleanly and a malformed blob reads as everything off.
*/
export const PREVIEW_FEATURE_KEYS = ["topics"] as const;
export type PreviewFeatureKey = (typeof PREVIEW_FEATURE_KEYS)[number];
export type PreviewFeatures = Record<PreviewFeatureKey, boolean>;
export const DEFAULT_PREVIEW_FEATURES: PreviewFeatures = { topics: false };
export function resolvePreviewFeatures(raw: unknown): PreviewFeatures {
const out: PreviewFeatures = { ...DEFAULT_PREVIEW_FEATURES };
if (raw === null || raw === undefined || typeof raw !== "object") return out;
const blob = raw as Record<string, unknown>;
for (const key of PREVIEW_FEATURE_KEYS) {
if (blob[key] === true) out[key] = true;
}
return out;
}
+2
View File
@@ -2154,6 +2154,8 @@ const accountSettingsBackupSchema = z
medicationListLayoutJson: z.unknown().optional(),
moodTagLayoutJson: z.unknown().optional(),
documentsLayoutJson: z.unknown().optional(),
moduleListLayoutJson: z.unknown().optional(),
previewFeaturesJson: z.unknown().optional(),
reportSelectionJson: z.unknown().optional(),
globalExcludedInjectionSites: z.array(z.string()).optional(),
healthKitConfigJson: z.unknown().optional(),
+37
View File
@@ -0,0 +1,37 @@
/**
* Request schemas for the per-user Settings preferences that are not part of
* the account payload: the list presentation of the labs, illness and
* checkups lists, and the preview-feature opt-ins. Shared by the routes and
* the OpenAPI registry so the wire contract has one source.
*/
import { z } from "zod/v4";
import {
MODULE_LIST_ID_MAX,
MODULE_LIST_KEYS,
MODULE_LIST_ORDER_MAX,
MODULE_LIST_VIEWS,
MODULE_SORT_DIRS,
} from "@/lib/settings/module-list-layout";
export const moduleListLayoutPutSchema = z
.object({
version: z.literal(1),
list: z.enum(MODULE_LIST_KEYS),
view: z.enum(MODULE_LIST_VIEWS).optional(),
order: z
.array(z.string().min(1).max(MODULE_LIST_ID_MAX))
.max(MODULE_LIST_ORDER_MAX)
.optional(),
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
})
.strict();
export const previewFeaturesPatchSchema = z
.object({
topics: z.boolean().optional(),
})
.strict()
.refine((body) => Object.keys(body).length > 0, {
message: "Name at least one preview feature.",
});
@@ -0,0 +1,216 @@
/**
* The two per-user Settings preferences that live outside the account
* payload, end to end against Postgres:
*
* - `/api/settings/module-list-layout`: defaults without a write, one list
* changed per PUT with preserve-when-absent fields and untouched neighbours,
* the multi-issue 422 for unknown values, and the column carrying only what
* was chosen.
* - `/api/settings/preview-features`: all off by default, PATCH keeps the
* flags it does not name, an unknown flag or an empty body is refused.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function seedUser(): Promise<{ userId: string }> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "settings-prefs-user",
email: "settings-prefs@example.test",
role: "USER",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return { userId: user.id };
}
type Handler = (req: Request) => Promise<Response>;
function json(method: string, url: string, body: unknown): Request {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("/api/settings/module-list-layout — integration", () => {
const URL = "http://localhost/api/settings/module-list-layout";
it("answers the defaults without writing a row", async () => {
const { userId } = await seedUser();
const { GET } = await import("@/app/api/settings/module-list-layout/route");
const res = await (GET as Handler)(new Request(URL));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { lists: Record<string, { view: string; saved: boolean }> };
};
expect(body.data.lists.labs).toMatchObject({ view: "list", saved: false });
expect(body.data.lists.illness).toMatchObject({
view: "cards",
saved: false,
});
const row = await getPrismaClient().user.findUnique({
where: { id: userId },
select: { moduleListLayoutJson: true },
});
expect(row?.moduleListLayoutJson).toBeNull();
});
it("changes one list per PUT and keeps the fields and lists it does not name", async () => {
const { userId } = await seedUser();
const { GET, PUT } =
await import("@/app/api/settings/module-list-layout/route");
const first = await (PUT as Handler)(
json("PUT", URL, { version: 1, list: "labs", order: ["m2", "m1"] }),
);
expect(first.status).toBe(200);
const second = await (PUT as Handler)(
json("PUT", URL, { version: 1, list: "labs", sortDir: "manual" }),
);
expect(second.status).toBe(200);
await (PUT as Handler)(
json("PUT", URL, { version: 1, list: "illness", view: "list" }),
);
const res = await (GET as Handler)(new Request(URL));
const body = (await res.json()) as {
data: {
lists: Record<
string,
{ view: string; order: string[]; sortDir: string; saved: boolean }
>;
};
};
expect(body.data.lists.labs).toEqual({
view: "list",
order: ["m2", "m1"],
sortDir: "manual",
saved: true,
});
expect(body.data.lists.illness).toMatchObject({
view: "list",
order: [],
saved: true,
});
expect(body.data.lists.vorsorge.saved).toBe(false);
// Only chosen fields are stored, so a default can still move later.
const row = await getPrismaClient().user.findUnique({
where: { id: userId },
select: { moduleListLayoutJson: true },
});
expect(row?.moduleListLayoutJson).toEqual({
version: 1,
labs: { order: ["m2", "m1"], sortDir: "manual" },
illness: { view: "list" },
});
});
it("refuses unknown lists, values and keys with every issue named", async () => {
await seedUser();
const { PUT } = await import("@/app/api/settings/module-list-layout/route");
const res = await (PUT as Handler)(
json("PUT", URL, {
version: 1,
list: "weight",
view: "grid",
extra: true,
}),
);
expect(res.status).toBe(422);
});
it("refuses a caller without a session", async () => {
const { GET } = await import("@/app/api/settings/module-list-layout/route");
const res = await (GET as Handler)(new Request(URL));
expect(res.status).toBe(401);
});
});
describe("/api/settings/preview-features — integration", () => {
const URL = "http://localhost/api/settings/preview-features";
it("is all off by default and keeps a flag once switched on", async () => {
const { userId } = await seedUser();
const { GET, PATCH } =
await import("@/app/api/settings/preview-features/route");
const before = await (GET as Handler)(new Request(URL));
expect(((await before.json()) as { data: unknown }).data).toEqual({
topics: false,
});
const on = await (PATCH as Handler)(json("PATCH", URL, { topics: true }));
expect(on.status).toBe(200);
expect(((await on.json()) as { data: unknown }).data).toEqual({
topics: true,
});
const after = await (GET as Handler)(new Request(URL));
expect(((await after.json()) as { data: unknown }).data).toEqual({
topics: true,
});
const row = await getPrismaClient().user.findUnique({
where: { id: userId },
select: { previewFeaturesJson: true },
});
expect(row?.previewFeaturesJson).toEqual({ topics: true });
});
it("refuses an unknown flag and an empty body", async () => {
await seedUser();
const { PATCH } = await import("@/app/api/settings/preview-features/route");
const unknown = await (PATCH as Handler)(
json("PATCH", URL, { topics: true, darkMagic: true }),
);
expect(unknown.status).toBe(422);
const empty = await (PATCH as Handler)(json("PATCH", URL, {}));
expect(empty.status).toBe(422);
});
});