Merge branch 'feat/v144-settings' into release/v1.44.0

# Conflicts:
#	src/components/settings/privacy-section.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 20:51:39 +02:00
commit e1b70537cc
129 files changed
+5138 -3169

No files matched your search

+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",
]);
});
});
@@ -7,6 +7,7 @@ import en from "../../../../messages/en.json";
import {
SETTINGS_CARDS,
SETTINGS_LAYOUT_PAGES,
SETTINGS_MODULE_PAGES,
} from "@/lib/command-palette/settings-cards";
/**
@@ -63,7 +64,11 @@ describe("SETTINGS_CARDS", () => {
);
it("every title key resolves", () => {
const missing = [...SETTINGS_CARDS, ...SETTINGS_LAYOUT_PAGES]
const missing = [
...SETTINGS_CARDS,
...SETTINGS_LAYOUT_PAGES,
...SETTINGS_MODULE_PAGES,
]
.map((c) => c.titleKey)
.filter((key) => !hasKey(key));
expect(missing).toEqual([]);
+21 -1
View File
@@ -20,7 +20,11 @@ import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareDomain } from "@/lib/sharing/scope";
import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES } from "./settings-cards";
import {
SETTINGS_CARDS,
SETTINGS_LAYOUT_PAGES,
SETTINGS_MODULE_PAGES,
} from "./settings-cards";
import { PALETTE_SYNONYMS } from "./synonyms";
/**
@@ -244,6 +248,22 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] {
});
}
}
if (sectionVisible.has("modules")) {
const modulesTitle = sectionTitle.get("modules") ?? "";
for (const page of SETTINGS_MODULE_PAGES) {
if (modules?.[page.module] === false) continue;
const id = `settings:modules/${page.slug}`;
entries.push({
id,
group: "settings",
title: t(page.titleKey),
hint: modulesTitle,
keywords: keywordsFor(id, [modulesTitle]),
icon: MODULE_ICONS[page.module],
run: { kind: "href", href: `/settings/modules/${page.slug}` },
});
}
}
}
// ── Module switches. ──
+37 -16
View File
@@ -95,11 +95,6 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
anchor: "retention",
titleKey: "settings.privacy.retention.title",
},
{
section: "privacy",
anchor: "your-data-export",
titleKey: "settings.privacy.export.title",
},
{
section: "privacy",
anchor: "delete-data",
@@ -111,17 +106,17 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
titleKey: "settings.privacy.posture.title",
},
{
section: "privacy",
section: "security",
anchor: "sessions",
titleKey: "settings.security.sessionsTitle",
},
{
section: "privacy",
section: "security",
anchor: "trusted-devices",
titleKey: "settings.security.trustedDevices.title",
},
{
section: "privacy",
section: "security",
anchor: "security-activity",
titleKey: "settings.security.activityTitle",
},
@@ -260,6 +255,11 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
titleKey: "notifications.lowStock.title",
modules: ["medications"],
},
{
section: "notifications",
anchor: "vorsorge-reminders",
titleKey: "notifications.checkupReminders.title",
},
// ── Anamnesis ──
{
section: "anamnesis",
@@ -355,7 +355,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
{
section: "coach",
anchor: "coach-activate",
titleKey: "settings.coach.activate.title",
titleKey: "settings.coach.state.title",
},
{
section: "coach",
@@ -363,7 +363,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
titleKey: "insights.coach.settingsTitle",
},
{
section: "coach",
section: "notifications",
anchor: "coach-nudge",
titleKey: "notifications.coachNudge.title",
},
@@ -429,7 +429,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
];
/**
* The Appearance hub's sub-pages, each its own entry. A module's page shows
* The Arrangement hub's sub-pages, each its own entry. A module's page shows
* only while the module is on.
*/
export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
@@ -444,11 +444,6 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
titleKey: "settings.sections.layout.medications.title",
module: "medications",
},
{
slug: "mood",
titleKey: "settings.sections.layout.mood.title",
module: "mood",
},
{
slug: "labs",
titleKey: "settings.sections.layout.labs.title",
@@ -466,3 +461,29 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
},
{ slug: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title" },
];
/**
* The module pages under Settings > Modules (`/settings/modules/<slug>`),
* each its own entry while its module is on.
*/
export const SETTINGS_MODULE_PAGES: ReadonlyArray<{
slug: string;
titleKey: string;
module: ModuleKey;
}> = [
{
slug: "medications",
titleKey: "settings.sections.modules.pages.medications.title",
module: "medications",
},
{
slug: "mood",
titleKey: "settings.sections.modules.pages.mood.title",
module: "mood",
},
{
slug: "labs",
titleKey: "settings.sections.modules.pages.labs.title",
module: "labs",
},
];
+3
View File
@@ -486,6 +486,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,
@@ -512,6 +513,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
@@ -1013,6 +1013,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 };
@@ -48,6 +48,7 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker";
import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
SCORE_RING_IDS,
@@ -160,6 +161,13 @@ describe("every surface id names a surface that exists", () => {
}
});
it("settings-module: a module page under Settings > Modules", () => {
const ids = new Set<string>(MODULE_SETTINGS_PAGE_IDS);
for (const id of localIds("settings-module")) {
expect(ids.has(id), id).toBe(true);
}
});
it("day-section: a section the day view renders", () => {
const keys = new Set<string>(DAY_SECTION_KEYS);
for (const id of localIds("day-section")) {
+7 -1
View File
@@ -46,6 +46,7 @@ import type { ModuleKey } from "@/lib/modules/registry";
* - `derived`: a derived score, by its `DerivedMetricId`.
* - `settings`: a Settings section, by its slug.
* - `settings-layout`: a Settings, Layout group, by its id.
* - `settings-module`: a module's own Settings page under Modules, by its id.
* - `correlation`: a correlation-discovery channel, by its channel key.
* - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`.
* - `day-section`: a section of the day view, by its `DaySectionKey`. The
@@ -66,6 +67,7 @@ export const SURFACE_KINDS = [
"derived",
"settings",
"settings-layout",
"settings-module",
"correlation",
"score-ring",
"day-section",
@@ -200,11 +202,15 @@ const STATIC_SURFACE_MODULE = {
// ── Settings, Layout groups ──
"settings-layout:medications": "medications",
"settings-layout:mood": "mood",
"settings-layout:labs": "labs",
"settings-layout:illness": "illness",
"settings-layout:documents": "inboundDocuments",
// ── Settings, a module's own page under Modules ──
"settings-module:medications": "medications",
"settings-module:mood": "mood",
"settings-module:labs": "labs",
// ── Correlation channels ──
// Rated mood factors (`FACTOR:<key>`) are mood's too; see
// `correlationChannelSurfaceId`.
+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
@@ -98,6 +98,7 @@ export const SETTINGS_DESTINATION_INVENTORY = {
export: PERSONAL,
advanced: PERSONAL,
privacy: PERSONAL,
preview: PERSONAL,
about: PERSONAL,
} as const satisfies Record<
SettingsSectionSlug,
@@ -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,
});
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* 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 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.",
});