mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
45da8ae971
commit
93198017d6
19 files changed
+1614
-208
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user