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

@@ -104,8 +104,12 @@ describe("AI-input writes evict the capability answer", () => {
it("finds the write sites it is meant to hold (an empty sweep is a broken matcher)", () => {
const total = sites.reduce((n, s) => n + s.writes.length, 0);
expect(sites.length).toBeGreaterThanOrEqual(21);
expect(total).toBeGreaterThanOrEqual(22);
// v1.44 — three write sites left on purpose: the Coach's second switch
// and the MCP page's second switch (the one switch is in Modules), and
// the Coach overlay's copy of the provider and model selects (chosen in
// Settings > AI only).
expect(sites.length).toBeGreaterThanOrEqual(19);
expect(total).toBeGreaterThanOrEqual(19);
});
it.each(sites.map((s) => [s.rel, s] as const))(
+15 -4
View File
@@ -39,8 +39,15 @@ import { describe, expect, it } from "vitest";
const SRC = join(process.cwd(), "src");
/** The prefix every customise shortcut points at. */
const LAYOUT_PREFIX = "/settings/layout/";
/**
* The prefixes a customise shortcut points at: a module's order and view
* under Arrangement, or (since v1.44) its own page under Modules.
*/
const SHORTCUT_PREFIXES = ["/settings/layout/", "/settings/modules/"];
function isShortcut(text: string): boolean {
return SHORTCUT_PREFIXES.some((prefix) => text.startsWith(prefix));
}
/** The answer a shared-record surface withholds these on: `!inSharedRecord`. */
const GUARD = "inSharedRecord";
@@ -62,6 +69,8 @@ const NOT_A_SHARED_DESTINATION: Record<string, string> = {
"The Insights page itself. Same flag, same reason.",
"components/insights/insights-edit-mode.tsx":
"The Insights arrange mode, reachable only from the page above.",
"components/settings/settings-anchor-forward.tsx":
"Not a shortcut: the table of old Settings anchors and where they moved, read on a Settings page, which the record gate already answers for.",
};
function allSourceFiles(): string[] {
@@ -127,7 +136,7 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
const visit = (node: ts.Node): void => {
if (
ts.isStringLiteralLike(node) &&
node.text.startsWith(LAYOUT_PREFIX) &&
isShortcut(node.text) &&
!guardedByOwnRecord(node)
) {
found.push(node.text);
@@ -140,7 +149,9 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
describe("the customise shortcut is withheld inside somebody else's record", () => {
const carrying = allSourceFiles().filter((rel) =>
readFileSync(join(SRC, rel), "utf8").includes(LAYOUT_PREFIX),
SHORTCUT_PREFIXES.some((prefix) =>
readFileSync(join(SRC, rel), "utf8").includes(prefix),
),
);
it("finds the surfaces that carry one", () => {
@@ -110,7 +110,7 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"every health record on the account, or the whole account including passkeys and audit history",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/security-sessions-card.tsx",
@@ -129,13 +129,13 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/security-section/passkey-list-section.tsx",
destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/security-section/security-keys-card.tsx",
destroys: "a WebAuthn second factor",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/ai/remove-saved-key-button.tsx",
@@ -156,19 +156,19 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/api-section.tsx",
destroys: "an API token — revoked, and the secret is never shown again",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/mcp-section.tsx",
destroys: "an MCP client grant or an MCP token",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/sharing-section.tsx",
destroys: "a clinician share link — the URL stays dead once revoked",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/account-section/avatar-section.tsx",
@@ -214,7 +214,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/coach-memory-section.tsx",
destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/insights/coach-panel/use-coach-facts.ts",
@@ -14,6 +14,7 @@ const recordRef: { value: { kind: "managed" | "shared"; level: string } } = {
vi.mock("next/navigation", () => ({
usePathname: () => pathnameRef.value,
useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -112,7 +113,9 @@ describe("managed record navigation", () => {
expect(
html.match(new RegExp(`href="/settings/${section.slug}"`, "g"))
?.length ?? 0,
).toBe(2);
// The sidebar carries the link; the phone's section sheet mounts its
// list only when opened (v1.44).
).toBe(1);
}
for (const section of unavailableDestinations) {
@@ -142,8 +142,6 @@ const RULES: ResolvedFieldRule[] = [
'import { resolveAiCapability } from "@/lib/ai/capabilities/resolve";',
],
allow: {
"src/components/settings/ai/disable-coach-card.tsx":
"the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value",
"src/hooks/use-module-toggle.ts":
"the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes",
"src/components/settings/managed-record-settings-section.tsx":
@@ -0,0 +1,55 @@
/**
* The German Settings glossary: one word per thing.
*
* Settings used "Provider" beside "Anbieter", "Default" beside "Standard",
* and the score as "Health Score", "Health-Score", "Gesundheits-Score" and
* "Gesundheitsscore", sometimes on one screen. The settled words are
* Anbieter, Standard and Gesundheitsscore; the Insights area is "Insights"
* (the name already dominant in the catalog). This reads the German catalog
* under the Settings namespaces and fails on the retired spellings.
*
* Product names are not glossary words: "ChatGPT", "OpenAI" and the like are
* left alone, and so is "Provider" inside a code-shaped token.
*/
import { describe, expect, it } from "vitest";
import de from "../../messages/de.json";
const NAMESPACES = ["settings", "thresholds"] as const;
const RETIRED: ReadonlyArray<{ pattern: RegExp; use: string }> = [
{ pattern: /\bProvider\b/, use: "Anbieter" },
{ pattern: /\bDefaults?\b/, use: "Standard" },
{ pattern: /\bHealth[- ]?Score\b/i, use: "Gesundheitsscore" },
{ pattern: /\bGesundheits-Score\b/, use: "Gesundheitsscore" },
];
function walk(node: unknown, path: string, out: Array<[string, string]>) {
if (typeof node === "string") out.push([path, node]);
else if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
walk(value, path ? `${path}.${key}` : key, out);
}
}
}
describe("German Settings glossary", () => {
const strings: Array<[string, string]> = [];
for (const ns of NAMESPACES) {
walk((de as Record<string, unknown>)[ns], ns, strings);
}
it("reads the catalog (an empty read is a broken walker)", () => {
expect(strings.length).toBeGreaterThan(500);
});
it("uses the settled words", () => {
const offenders: string[] = [];
for (const [key, value] of strings) {
for (const { pattern, use } of RETIRED) {
if (pattern.test(value)) offenders.push(`${key}: "${value}" → ${use}`);
}
}
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,191 @@
/**
* Every Settings address a person ever had keeps working.
*
* Settings has been regrouped many times (v1.4 split, v1.17 layout hub,
* v1.25 Darstellung, v1.44 nine groups), and every bookmark, cross-link in
* an older notification and docs page still points at one of those shapes.
* This walks the full list of historical `/settings/*` addresses through the
* redirects in `next.config.ts` and asserts each one lands on a route that
* renders: a section slug, an Arrangement sub-page or a module page. It does
* the same for the anchors a page forwards client-side when a card moved
* while its page stayed (`settings-anchor-forward.tsx`).
*
* A route removed without a redirect, a redirect pointing at a page that no
* longer exists, or a forward whose target 404s fails here by name.
*/
import { describe, expect, it } from "vitest";
import nextConfig from "../../next.config";
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import {
SETTINGS_ANCHOR_FORWARDS,
forwardedAnchorTarget,
} from "@/components/settings/settings-anchor-forward";
/** Every Settings path that was ever reachable, newest shapes included. */
const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
"/settings",
// Section slugs of every release since the v1.4 split.
"/settings/account",
"/settings/security",
"/settings/access",
"/settings/modules",
"/settings/integrations",
"/settings/sources",
"/settings/channels",
"/settings/notifications",
"/settings/reminders",
"/settings/layout",
"/settings/environment",
"/settings/anamnesis",
"/settings/score",
"/settings/thresholds",
"/settings/ai",
"/settings/coach",
"/settings/api",
"/settings/mcp",
"/settings/gesundheitsakte",
"/settings/sharing",
"/settings/export",
"/settings/advanced",
"/settings/privacy",
"/settings/about",
// The per-module personalisation slugs before the layout hub.
"/settings/dashboard",
"/settings/insights",
"/settings/medications",
"/settings/mood",
"/settings/labs",
"/settings/illness",
"/settings/vorsorge",
// Layout hub sub-pages since v1.25.11.
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/layout/labs",
"/settings/layout/illness",
"/settings/layout/documents",
"/settings/layout/vorsorge",
];
interface Redirect {
source: string;
destination: string;
}
async function settingsRedirects(): Promise<Redirect[]> {
const all = (await nextConfig.redirects?.()) ?? [];
return all
.filter((r) => r.source.startsWith("/settings"))
.map((r) => ({ source: r.source, destination: r.destination }));
}
/** Follow exact-match redirects until none applies; the hash is dropped. */
function resolve(path: string, redirects: readonly Redirect[]): string {
let current = path;
for (let hop = 0; hop < 5; hop += 1) {
const next = redirects.find((r) => r.source === current);
if (!next) return current;
current = next.destination.split("#")[0]!;
}
throw new Error(`redirect loop from ${path}`);
}
/** Does a path (no hash) render a Settings page? */
function renders(path: string): boolean {
if (path === "/settings") return true; // the page redirects to account
const parts = path.split("/").filter(Boolean);
if (parts[0] !== "settings") return false;
if (parts.length === 2) {
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(parts[1]!);
}
if (parts.length === 3 && parts[1] === "layout") {
return LAYOUT_GROUP_IDS.includes(parts[2]!);
}
if (parts.length === 3 && parts[1] === "modules") {
return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(parts[2]!);
}
return false;
}
describe("historical Settings addresses", () => {
it("reads the redirects from next.config.ts (an empty read is a broken matcher)", async () => {
const redirects = await settingsRedirects();
expect(redirects.length).toBeGreaterThanOrEqual(10);
});
it.each(HISTORICAL_SETTINGS_PATHS)(
"%s still lands on a page",
async (path) => {
const redirects = await settingsRedirects();
const landed = resolve(path, redirects);
expect(renders(landed), `${path} → ${landed}`).toBe(true);
},
);
it("every redirect into Settings points at a page that renders", async () => {
for (const r of await settingsRedirects()) {
const target = r.destination.split("#")[0]!;
expect(renders(target), `${r.source} → ${r.destination}`).toBe(true);
}
});
it("every routable section, sub-page and module page is on the list", () => {
const routable = [
...SETTINGS_SECTION_SLUGS.map((s) => `/settings/${s}`),
...LAYOUT_GROUP_IDS.map((id) => `/settings/layout/${id}`),
...MODULE_SETTINGS_PAGE_IDS.map((id) => `/settings/modules/${id}`),
];
// New routes join the list when they ship, so a later move cannot drop
// one silently. Module pages are new in v1.44 and are allowed here.
const listed = new Set(HISTORICAL_SETTINGS_PATHS);
const unlisted = routable.filter(
(path) =>
!listed.has(path) &&
!path.startsWith("/settings/modules/") &&
path !== "/settings/preview",
);
expect(unlisted).toEqual([]);
});
it("keeps the v1.44 moves addressable", async () => {
const redirects = await settingsRedirects();
expect(resolve("/settings/layout/mood", redirects)).toBe(
"/settings/modules/mood",
);
expect(resolve("/settings/mood", redirects)).toBe("/settings/modules/mood");
});
});
describe("anchors forwarded after a card moved", () => {
const forwards = Object.entries(SETTINGS_ANCHOR_FORWARDS).flatMap(
([page, anchors]) =>
Object.entries(anchors).map(([anchor, target]) => ({
page,
anchor,
target,
})),
);
it("has forwards to check", () => {
expect(forwards.length).toBeGreaterThanOrEqual(8);
});
it.each(forwards)(
"$page#$anchor forwards to a page that renders",
({ page, anchor, target }) => {
expect(renders(page), page).toBe(true);
expect(forwardedAnchorTarget(page, `#${anchor}`)).toBe(target);
expect(renders(target.split("#")[0]!), target).toBe(true);
},
);
it("leaves an anchor that did not move alone", () => {
expect(forwardedAnchorTarget("/settings/privacy", "#retention")).toBeNull();
expect(forwardedAnchorTarget("/settings/security", "#sessions")).toBeNull();
expect(forwardedAnchorTarget("/settings/privacy", "")).toBeNull();
});
});
@@ -348,7 +348,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/dashboard-layout-section.tsx": {
"toast.success": 2,
},
"src/components/settings/email-card.tsx": { "text-success": 1 },
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
"src/components/settings/insights-pill-order-section.tsx": {
"toast.success": 1,
@@ -383,13 +382,12 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/mcp-section.tsx": {
"text-success": 4,
"toast.success": 2,
"toast.success": 1,
},
"src/components/settings/notification-status-card.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
// The one that used to sit inside `account-section/index.tsx` — the card
// moved to Settings → Security, the line moved with it.
"src/components/settings/password-card.tsx": { "text-success": 1 },
@@ -397,7 +395,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
"src/components/settings/sources-section.tsx": { "toast.success": 2 },
"src/components/settings/telegram-card.tsx": { "text-success": 1 },
"src/components/settings/test-connection-button.tsx": {
CheckCircle2: 2,
"text-success": 1,
@@ -407,7 +404,6 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/trusted-devices-card.tsx": { "text-success": 1 },
"src/components/settings/web-push-card.tsx": { "text-success": 1 },
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
"src/components/ui/password-strength.tsx": { "text-success": 2 },
// The success tone of the grade chip (a high confidence, a score that went
@@ -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 -1
View File
@@ -153,7 +153,7 @@ export default function LabsPage() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/labs"
href="/settings/modules/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
+1 -1
View File
@@ -150,7 +150,7 @@ export default function MoodPageClient() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/mood"
href="/settings/modules/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function SettingsSectionPage({ params }: PageProps) {
}
// Each section is its own chunk (see `SettingsSectionLoader`), so the route
// ships the one it shows rather than all twenty-one. Integrations is the
// ships the one it shows rather than all of them. Integrations is the
// one section that needs a server-resolved value: the OAuth callback URL
// each provider registers, read from the runtime env here, per request,
// because a `NEXT_PUBLIC_*` read inside a client module is inlined at build
@@ -9,6 +9,8 @@ import {
isLayoutGroupId,
} from "@/components/settings/layout-groups";
import { SettingsShell } from "@/components/settings/settings-shell";
import { ModuleSettingsLinkCard } from "@/components/settings/module-settings-pages";
import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -86,6 +88,11 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleKey={layoutGroupModule(group.id)}>
<Body />
{/* v1.44 — what the module needs beyond order and view lives
on its page under Modules; say where. */}
{isModuleSettingsPageId(group.id) ? (
<ModuleSettingsLinkCard id={group.id} />
) : null}
</LayoutModuleGate>
</section>
</RecordSettingsSectionGate>
@@ -0,0 +1,72 @@
import { notFound } from "next/navigation";
import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
import { ModuleSettingsPageBody } from "@/components/settings/module-settings-pages";
import {
MODULE_SETTINGS_PAGE_IDS,
isModuleSettingsPageId,
moduleSettingsPageKeys,
moduleSettingsPageModule,
} from "@/components/settings/module-settings-page-ids";
import { SettingsShell } from "@/components/settings/settings-shell";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* A module's own settings: `/settings/modules/<id>`.
*
* What a module needs beyond order and view (the labs biomarker catalog and
* on-device text recognition, the mood tags, the injection sites) lives here,
* under Settings > Modules, with a back link to the Modules list. Pre-rendered
* for the known ids; any other segment 404s at routing time.
*/
export const dynamicParams = false;
export function generateStaticParams() {
return MODULE_SETTINGS_PAGE_IDS.map((module) => ({ module }));
}
interface PageProps {
params: Promise<{ module: string }>;
}
export default async function SettingsModulePage({ params }: PageProps) {
const { module } = await params;
if (!isModuleSettingsPageId(module)) notFound();
const locale = await resolveServerLocale();
const { t } = getServerTranslator(locale);
const { titleKey, descriptionKey } = moduleSettingsPageKeys(module);
const headingId = `settings-section-modules-${module}-title`;
return (
<SettingsShell
active="modules"
heading={{
title: t(titleKey),
subtitle: t(descriptionKey),
headingId,
topSlot: (
<SettingsHubBackLink
href="/settings/modules"
labelKey="settings.sections.modules.backToHub"
/>
),
}}
>
{/* The cards here lived under Arrangement until v1.44 and keep its
posture inside somebody else's record, so the gate is asked about
`layout`, not about the Modules switches. */}
<RecordSettingsSectionGate section="layout">
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleKey={moduleSettingsPageModule(module)}>
<ModuleSettingsPageBody id={module} />
</LayoutModuleGate>
</section>
</RecordSettingsSectionGate>
</SettingsShell>
);
}
@@ -1,24 +1,8 @@
import { describe, expect, it } from "vitest";
import type {
ProviderChainData,
UserAIProvider,
} from "@/components/settings/ai/shared";
import type { UserAIProvider } from "@/components/settings/ai/shared";
import {
chainWithFirst,
modelControlFor,
selectableProviders,
switchDidNotTake,
} from "../coach-model-picker";
const CHAIN: ProviderChainData["configuredChain"] = [
{ providerType: "codex", enabled: true, available: false },
{ providerType: "openai", enabled: true, available: true },
{ providerType: "anthropic", enabled: false, available: true },
{ providerType: "local", enabled: true, available: true },
{ providerType: "admin-openai", enabled: true, available: true },
];
import { modelControlFor } from "../coach-model-picker";
function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
return {
@@ -40,55 +24,6 @@ function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
};
}
describe("chainWithFirst", () => {
it("moves the chosen provider to the front and keeps the rest in order", () => {
expect(chainWithFirst(CHAIN, "local")).toEqual([
{ providerType: "local", priority: 1, enabled: true },
{ providerType: "codex", priority: 2, enabled: true },
{ providerType: "openai", priority: 3, enabled: true },
{ providerType: "anthropic", priority: 4, enabled: false },
{ providerType: "admin-openai", priority: 5, enabled: true },
]);
});
it("never sends a reasoning setting, so the server keeps the stored one", () => {
for (const entry of chainWithFirst(CHAIN, "openai")) {
expect(Object.keys(entry).sort()).toEqual([
"enabled",
"priority",
"providerType",
]);
}
});
});
describe("selectableProviders", () => {
it("offers only enabled providers that can answer", () => {
expect(selectableProviders(CHAIN)).toEqual([
"openai",
"local",
"admin-openai",
]);
});
});
describe("switchDidNotTake", () => {
it("is quiet when the chosen provider leads the chain the server resolves", () => {
expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
false,
);
});
it("speaks up when the server still answers with another provider", () => {
// A provider without a working key was saved to the front of the chain;
// the resolver skipped it and the select would jump back silently.
expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
true,
);
expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
});
});
describe("modelControlFor", () => {
it("offers the presets for the provider whose key the person saved", () => {
const control = modelControlFor("openai", userProvider());
@@ -70,41 +70,32 @@ describe("<CoachSettingsBody>", () => {
expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings</);
});
it("offers only the providers that can answer, with the active one selected", () => {
it("shows the provider and model in effect and links to the one place they are chosen", () => {
const html = render(<CoachSettingsBody />);
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toBeDefined();
expect(select).toContain('value="local"');
expect(select).toContain('value="admin-openai"');
expect(select).not.toContain('value="codex"');
expect(html).toMatch(
/data-slot="coach-quick-provider"[^>]*data-value="local"/,
);
expect(html).toMatch(
/data-slot="coach-quick-model"[^>]*data-value="qwen2.5"/,
);
expect(html).toContain('href="/settings/ai#ai-provider"');
// v1.44 — no second copy of the selects here.
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-provider"/);
expect(html).not.toMatch(/<select[^>]*data-slot="coach-quick-model"/);
expect(html).toContain('id="coach-quick-reasoning"');
// Keys and base URLs are not here.
expect(html).not.toContain("http://localhost:11434/v1");
expect(html).not.toMatch(/type="password"/);
});
it("shows the operator's central Codex as the active provider when it answers", () => {
// `admin-codex` is never a chain entry, so no option matched it and the
// select showed its first option as the one answering.
it("names the operator's central Codex when it answers", () => {
const html = render(<CoachSettingsBody />, {
...CHAIN,
activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
});
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toMatch(
/<option value="admin-codex" disabled="" selected="">ChatGPT \(shared by the operator\)<\/option>/,
expect(html).toMatch(
/data-slot="coach-quick-provider"[^>]*data-value="admin-codex"[^>]*>ChatGPT \(shared by the operator\)</,
);
expect(select).not.toMatch(/value="local"[^>]*selected/);
expect(html).toContain('data-slot="coach-quick-provider-operator"');
});
it("shows the model and reasoning controls the active provider owns", () => {
const html = render(<CoachSettingsBody />);
expect(html).toContain('data-slot="coach-quick-model"');
expect(html).toContain('id="coach-quick-reasoning"');
// Keys, base URLs and the chain order are not here.
expect(html).not.toContain("http://localhost:11434/v1");
expect(html).not.toMatch(/type="password"/);
});
it("locks the reasoning select while a change is saving", async () => {
@@ -1,13 +1,9 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { ChevronRight, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
ANTHROPIC_MODEL_PRESETS,
LOCAL_MODEL_PRESETS,
@@ -17,85 +13,23 @@ import {
type ProviderType,
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* The quick model choice in the Coach's settings overlay: which provider
* answers and which model it uses. How hard the Coach thinks is the Coach's
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
* reasoning select stays in Settings → AI for everything else.
* Which provider answers the Coach and which model it uses, shown in the
* Coach's settings overlay with a link to where it is chosen.
*
* It is a shortcut onto the same settings Settings → AI writes, never a second
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
* fallback chain stay there; this reads the same two queries and writes
* through the same two routes:
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
* to the front and every other entry in its current order. The entries'
* reasoning settings are not sent, so the server keeps what it stored.
* - Model: `PATCH /api/user/ai-provider`, only where a person
* owns the setting (their own key's provider, or their gateway). The
* operator's shared provider and Codex show their state read-only.
* Every write invalidates the AI input keys, so the capability on
* `/api/auth/me` comes back from the server rather than being guessed here.
* The model is chosen in one place, Settings > AI. Until v1.44 this overlay
* carried a second copy of the provider and model selects, writing through
* the same routes; two controls for one setting meant a change in one place
* surprised the person in the other. The overlay now reads the same two
* queries and says what is in effect.
*/
/** The configured chain with `provider` first; everything else keeps order. */
export function chainWithFirst(
chain: ProviderChainData["configuredChain"],
provider: ProviderType,
): { providerType: ProviderType; priority: number; enabled: boolean }[] {
const rest = chain.filter((entry) => entry.providerType !== provider);
const ordered = [
{ providerType: provider, enabled: true },
...rest.map((entry) => ({
providerType: entry.providerType,
enabled: entry.enabled,
})),
];
return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
}
/** The providers a person can switch to: enabled and able to answer. */
export function selectableProviders(
chain: ProviderChainData["configuredChain"],
): ProviderType[] {
return chain
.filter((entry) => entry.enabled && entry.available)
.map((entry) => entry.providerType);
}
/**
* True when a saved switch did not take: the chain the server resolves after
* the save is not led by the provider the person chose. A provider whose key
* stops working between the read and the save lands here.
*/
export function switchDidNotTake(
chosen: ProviderType,
fresh: Pick<ProviderChainData, "activeProvider">,
): boolean {
return fresh.activeProvider !== chosen;
}
/**
* The provider the server answers with when the select has no option for it:
* the operator's central Codex (never a chain entry), or an entry that answers
* without being offered. Shown as a disabled option so the select tells the
* truth instead of showing its first option as active.
*/
export function activeOutsideOptions(
active: string | null,
options: readonly string[],
): string | null {
return active !== null && !options.includes(active) ? active : null;
}
export type ModelControl =
| { kind: "preset"; presets: readonly string[]; value: string | null }
| { kind: "gateway"; value: string | null }
@@ -130,7 +64,6 @@ export function modelControlFor(
export function CoachModelPicker() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const chainQuery = useQuery({
queryKey: queryKeys.insightsProviderChain(),
@@ -141,59 +74,6 @@ export function CoachModelPicker() {
queryFn: () => apiGet<UserAIProvider>("/api/user/ai-provider"),
});
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const onSaved = () => {
setError(null);
void invalidateKeys(queryClient, aiInputDependentKeys);
};
const onFailed = (e: unknown) =>
setError(
e instanceof Error && e.message
? e.message
: t("insights.coach.frame.saveFailed"),
);
const switchProvider = useMutation({
mutationFn: async (provider: ProviderType) => {
setNotice(null);
await apiPut("/api/insights/provider-chain", {
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
});
// Read back what the server now resolves, so a switch that did not
// take is said rather than shown as the select jumping back.
return apiGet<ProviderChainData>("/api/insights/provider-chain");
},
onSuccess: (fresh, provider) => {
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
onSaved();
if (switchDidNotTake(provider, fresh)) {
setNotice(
t("insights.coach.frame.providerNotActive", {
provider: t(`settings.ai.providerChain.types.${provider}`),
}),
);
}
},
onError: onFailed,
});
const patchProvider = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiPatch("/api/user/ai-provider", body),
onSuccess: onSaved,
onError: onFailed,
});
const chain = chainQuery.data;
const userProvider = providerQuery.data;
const active = chain?.activeProvider ?? null;
const options = chain ? selectableProviders(chain.configuredChain) : [];
const model = modelControlFor(active, userProvider);
const busy = switchProvider.isPending || patchProvider.isPending;
// The server may answer with `admin-codex`, which the UI's type omits.
const unlisted = activeOutsideOptions(active as string | null, options);
if (chainQuery.isError || providerQuery.isError) {
return (
<QueryErrorRow
@@ -208,6 +88,8 @@ export function CoachModelPicker() {
);
}
const chain = chainQuery.data;
const userProvider = providerQuery.data;
if (!chain || !userProvider) {
return (
<p
@@ -223,159 +105,45 @@ export function CoachModelPicker() {
);
}
// The server may answer with `admin-codex`, which the UI's type omits.
const active = (chain.activeProvider ?? null) as string | null;
const model = modelControlFor(chain.activeProvider ?? null, userProvider);
const providerLabel =
active === null
? t("insights.coach.frame.noProvider")
: active === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${active}`);
const modelLabel =
model.kind === "managed"
? t("insights.coach.frame.modelManaged")
: (model.value ?? t("settings.ai.modelOptionDefault"));
return (
<div data-slot="coach-model-picker" className="flex flex-col gap-4">
<div>
<Label htmlFor="coach-quick-provider" noColon>
<div data-slot="coach-model-picker" className="flex flex-col gap-3">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">
{t("insights.coach.frame.provider")}
</Label>
<NativeSelect
id="coach-quick-provider"
data-slot="coach-quick-provider"
className="mt-1"
value={active ?? ""}
disabled={busy || options.length === 0}
onChange={(e) => {
const next = e.target.value as ProviderType;
if (next && next !== active) switchProvider.mutate(next);
}}
</dt>
<dd data-slot="coach-quick-provider" data-value={active ?? ""}>
{providerLabel}
</dd>
<dt className="text-muted-foreground">{t("settings.ai.modelLabel")}</dt>
<dd
data-slot="coach-quick-model"
data-value={model.kind === "managed" ? "" : (model.value ?? "")}
>
{active === null ? (
<option value="" disabled>
{t("insights.coach.frame.noProvider")}
</option>
) : null}
{unlisted ? (
<option value={unlisted} disabled>
{unlisted === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${unlisted}`)}
</option>
) : null}
{options.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerChain.types.${p}`)}
</option>
))}
</NativeSelect>
{unlisted === "admin-codex" ? (
<p
data-slot="coach-quick-provider-operator"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.coach.frame.operatorManaged")}
</p>
) : null}
</div>
{model.kind === "preset" ? (
<div>
<Label htmlFor="coach-quick-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<NativeSelect
id="coach-quick-model"
data-slot="coach-quick-model"
className="mt-1"
value={model.value ?? ""}
disabled={busy}
onChange={(e) =>
patchProvider.mutate({ model: e.target.value || null })
}
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{model.value && !model.presets.includes(model.value) ? (
<option value={model.value}>{model.value}</option>
) : null}
{model.presets.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</NativeSelect>
</div>
) : model.kind === "gateway" ? (
<GatewayModelField
key={model.value ?? ""}
value={model.value}
disabled={busy}
onSave={(next) => patchProvider.mutate({ compatModel: next })}
/>
) : (
<p
data-slot="coach-quick-model-managed"
className="text-muted-foreground text-xs"
>
{t("insights.coach.frame.modelManaged")}
</p>
)}
{busy ? (
<p role="status" className="text-muted-foreground text-xs">
{t("insights.coach.frame.saving")}
</p>
) : null}
{notice ? (
<p
role="status"
data-slot="coach-quick-provider-notice"
className="text-muted-foreground text-xs"
>
{notice}
</p>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
{modelLabel}
</dd>
</dl>
<p>
<InlineAction asChild tone="muted">
<Link href="/settings/ai#ai-provider" data-slot="coach-quick-change">
{t("insights.coach.frame.changeModel")}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
</p>
</div>
);
}
/** A gateway names its own models, so its model is free text with a save. */
function GatewayModelField({
value,
disabled,
onSave,
}: {
value: string | null;
disabled: boolean;
onSave: (next: string | null) => void;
}) {
const { t } = useTranslations();
const [draft, setDraft] = useState(value ?? "");
const changed = draft.trim() !== (value ?? "");
return (
<form
className="flex flex-col gap-1"
onSubmit={(e) => {
e.preventDefault();
if (changed) onSave(draft.trim() || null);
}}
>
<Label htmlFor="coach-quick-gateway-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
id="coach-quick-gateway-model"
data-slot="coach-quick-gateway-model"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={disabled}
className="min-w-0 flex-1"
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || !changed}
className="min-h-11 sm:min-h-9"
>
{t("settings.ai.saveCta")}
</Button>
</div>
</form>
);
}
+22 -16
View File
@@ -1,17 +1,16 @@
"use client";
/**
* v1.18.6 — the Labs module's own settings page body.
* The Labs module's settings, in two places since v1.44:
*
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
* user:
* - pick the card-vs-list page view (MOD-03),
* - choose the sort order (by most-recent reading, descending / ascending,
* or manual) — MOD-04,
* - reorder biomarkers when sort = manual (MOD-03/04),
* - and create / edit / delete biomarkers via the existing
* `BiomarkerManager` (MOD-04).
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
* order of the biomarkers. Order and view only, persisted per account through
* `useModuleListPrefs("labs")`.
*
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
* recognition opt-in and the biomarker catalog (`BiomarkerManager`). Reached
* from the wrench on the labs page.
*/
import { useQuery } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
@@ -52,12 +51,6 @@ export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
// can't read images still scan a report: the image is OCR'd on-device and
// only the extracted text is sent to the text-only provider.
const localOcr = useLabsLocalOcr();
const updateLocalOcr = useUpdateLabsLocalOcr();
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
@@ -130,7 +123,20 @@ export function LabsSettings() {
)}
</SettingsCard>
) : null}
</div>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
// can't read images still scan a report: the image is OCR'd on-device and
// only the extracted text is sent to the text-only provider.
const localOcr = useLabsLocalOcr();
const updateLocalOcr = useUpdateLabsLocalOcr();
return (
<div className="space-y-6">
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
<SettingsCard id="labs-local-ocr" className="scroll-mt-28">
<SettingsCardHeader
@@ -1,13 +1,14 @@
"use client";
/**
* v1.18.6 — the Vorsorge module's own settings page body.
* The checkups list in Settings, in two parts since v1.44:
*
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
* user reorder reminders + pick the card-vs-list page view, and re-homes the
* per-reminder enable/disable toggles that used to live in the page's
* "Anpassen" sheet. Order + view persist client-side via
* `useModuleListPrefs("vorsorge")`; the toggles persist through the existing
* `VorsorgeSettings` — Settings > Arrangement > Checkups: the card-vs-list
* view and the manual order, persisted per account through
* `useModuleListPrefs("vorsorge")`.
*
* `VorsorgeReminderSwitchesCard` — the per-reminder on/off switches, under
* Settings > Notifications with the other reminders, persisted through the
* reminder PATCH route.
*/
import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
@@ -47,7 +48,6 @@ function resolveLabel(
export function VorsorgeSettings() {
const { t } = useTranslations();
const { data: reminders, isLoading } = useMeasurementReminders(true);
const { update } = useMeasurementReminderMutations();
const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge");
const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id);
@@ -59,7 +59,7 @@ export function VorsorgeSettings() {
: t("measurementReminders.selfPlanned"),
}));
// v1.18.10 — the three Vorsorge settings groups adopt the shared
// v1.18.10 — the Vorsorge settings groups adopt the shared
// `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered
// surface, neutral muted icon, `text-lg` title, short muted description)
// so this surface reads like every other Settings section instead of bare
@@ -96,67 +96,80 @@ export function VorsorgeSettings() {
)}
</div>
</SettingsCard>
<SettingsCard>
<SettingsCardHeader
icon={ListChecks}
title={t("measurementReminders.manage.title")}
description={t("measurementReminders.manage.description")}
/>
<div>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("measurementReminders.manage.empty")}
/>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
{ordered.map((reminder) => (
<div
key={reminder.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span
id={`vorsorge-reminder-label-${reminder.id}`}
className="min-w-0 truncate text-sm"
>
{resolveLabel(reminder, t)}
</span>
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
update.mutate(
{ id: reminder.id, body: { enabled: next } },
{
onSuccess: () =>
toastWrittenOutcome(
"success",
t("settings.saved"),
),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
},
)
}
disabled={update.isPending}
// Label each row's Switch by its own name span so SR
// users hear the reminder name (plus the switch role's
// on/off state) instead of N identical generic labels.
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
/>
</div>
))}
</CardContent>
</Card>
)}
</div>
</SettingsCard>
</div>
);
}
/**
* The on/off switch of each preventive-care reminder. A reminder, so since
* v1.44 it lives with the other reminders under Settings > Notifications
* (`#vorsorge-reminders`, the wrapper there); the checkups page links
* there.
*/
export function VorsorgeReminderSwitchesCard() {
const { t } = useTranslations();
const { data: reminders, isLoading } = useMeasurementReminders(true);
const { update } = useMeasurementReminderMutations();
const { prefs } = useModuleListPrefs("vorsorge");
const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id);
return (
<SettingsCard>
<SettingsCardHeader
icon={ListChecks}
title={t("notifications.checkupReminders.title")}
description={t("notifications.checkupReminders.description")}
/>
<div>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("measurementReminders.manage.empty")}
/>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
{ordered.map((reminder) => (
<div
key={reminder.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span
id={`vorsorge-reminder-label-${reminder.id}`}
className="min-w-0 truncate text-sm"
>
{resolveLabel(reminder, t)}
</span>
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
update.mutate(
{ id: reminder.id, body: { enabled: next } },
{
onSuccess: () =>
toastWrittenOutcome("success", t("settings.saved")),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
},
)
}
disabled={update.isPending}
// Label each row's Switch by its own name span so SR
// users hear the reminder name (plus the switch role's
// on/off state) instead of N identical generic labels.
aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
/>
</div>
))}
</CardContent>
</Card>
)}
</div>
</SettingsCard>
);
}
@@ -2,10 +2,11 @@
/**
* v1.43 (#614) — screening hints from the life circumstances, in Settings →
* Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound,
* each with its status, the reasons in the account's language and the rule's
* source and country. A hint, never a reminder: nothing is scheduled here,
* and the card says so. Resolved on the server; rendered as delivered.
* Health profile → Medical history (under Checkups until v1.44). Low-dose CT
* for lung cancer and the abdominal-aorta ultrasound, each with its status,
* the reasons in the account's language and the rule's source and country.
* A hint, never a reminder: nothing is scheduled here, and the card says so.
* Resolved on the server; rendered as delivered.
*
* The card stays away until it has something to say: the person has said
* something about smoking, or a hint already applies without it (the German
@@ -218,7 +218,7 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
// both ship without the "KI"/"AI" prefix per the project-voice rule
// ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is
// labelled for what it does: it chooses the form, not the order.
expect(html).toContain("Aktiver Provider");
expect(html).toContain("Aktiver Anbieter");
expect(html).toContain("Anbieter einrichten");
});
@@ -243,6 +243,6 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
it("no longer mounts the Coach cards (moved to the Coach section)", () => {
activeProviderQuery = "codex";
const html = render();
expect(html).not.toContain('data-testid="settings-disable-coach-card"');
expect(html).not.toContain('data-slot="settings-coach-state-card"');
});
});
@@ -35,6 +35,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => null,
}));
vi.mock("@/components/records/screening-hints-card", () => ({
ScreeningHintsCard: () => null,
}));
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
@@ -18,6 +18,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => <div data-slot="conditions" />,
}));
vi.mock("@/components/records/screening-hints-card", () => ({
ScreeningHintsCard: () => null,
}));
vi.mock("@/components/records/life-circumstances-manager", () => ({
LifeCircumstancesManager: () => null,
}));
@@ -21,6 +21,7 @@ describe("channel form dirty state", () => {
headerName: "X-Gotify-Key",
headerValue: "",
format: "gotify" as const,
enabled: false,
};
it("webhook: a form that matches the saved config is clean", () => {
@@ -56,7 +57,12 @@ describe("channel form dirty state", () => {
it("ntfy: server, topic and a typed auth token count", () => {
const saved = { serverUrl: "https://ntfy.sh", topic: "t" };
const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" };
const form = {
serverUrl: "https://ntfy.sh",
topic: "t",
authToken: "",
enabled: false,
};
expect(isNtfyFormDirty(form, saved)).toBe(false);
expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true);
expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe(
@@ -67,39 +73,86 @@ describe("channel form dirty state", () => {
it("email: the recipient counts", () => {
expect(
isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }),
isEmailFormDirty(
{ recipient: "a@x.io", enabled: false },
{ recipient: "a@x.io" },
),
).toBe(false);
expect(
isEmailFormDirty({ recipient: "b@x.io" }, { recipient: "a@x.io" }),
isEmailFormDirty(
{ recipient: "b@x.io", enabled: false },
{ recipient: "a@x.io" },
),
).toBe(true);
});
it("telegram: a typed bot token or a changed chat id counts; whitespace alone does not", () => {
expect(
isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }),
).toBe(false);
expect(
isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }),
isTelegramFormDirty(
{ botToken: "", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(false);
expect(
isTelegramFormDirty(
{ botToken: "123:abc", chatId: "1" },
{ botToken: " ", chatId: "", enabled: false },
{ chatId: null },
),
).toBe(false);
expect(
isTelegramFormDirty(
{ botToken: "123:abc", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(true);
expect(
isTelegramFormDirty({ botToken: "", chatId: "2" }, { chatId: "1" }),
isTelegramFormDirty(
{ botToken: "", chatId: "2", enabled: false },
{ chatId: "1" },
),
).toBe(true);
});
it("a flipped enable switch is a change on every channel, since it saves with the form", () => {
expect(
isWebhookFormDirty({ ...webhookForm, enabled: true }, webhookSaved),
).toBe(true);
expect(
isNtfyFormDirty(
{ serverUrl: "s", topic: "t", authToken: "", enabled: true },
{ serverUrl: "s", topic: "t", enabled: false },
),
).toBe(true);
expect(
isEmailFormDirty(
{ recipient: "a@x.io", enabled: false },
{ recipient: "a@x.io", enabled: true },
),
).toBe(true);
expect(
isTelegramFormDirty(
{ botToken: "", chatId: "1", enabled: true },
{ chatId: "1", enabled: true },
),
).toBe(false);
});
it("nothing loaded yet is never a change", () => {
expect(isWebhookFormDirty(webhookForm, undefined)).toBe(false);
expect(
isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined),
isNtfyFormDirty(
{ serverUrl: "", topic: "", authToken: "x", enabled: true },
undefined,
),
).toBe(false);
expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false);
expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe(
expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe(
false,
);
expect(
isTelegramFormDirty(
{ botToken: "x", chatId: "", enabled: true },
undefined,
),
).toBe(false);
});
});
@@ -12,8 +12,7 @@
* The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts`
* → `queryKeys.coachFacts()` invalidation + toast) is pinned by the
* route-shape contract in the component plus the typecheck gate; this
* suite guards the render contract the way the sibling
* `disable-coach-card.test.tsx` does for the toggle.
* suite guards the render contract.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -1,10 +1,10 @@
/**
* v1.18.0 — Settings → Coach section.
*
* The Coach preference cards (disable toggle, preferences, memory) moved
* out of the AI / Assistant section into their own dedicated, module-gated
* entry. SSR-only smoke test — interactive contracts live in the cards'
* own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`).
* The Coach preference cards (state, preferences, memory) moved out of the
* AI / Assistant section into their own dedicated, module-gated entry.
* SSR-only smoke test — interactive contracts live in the cards' own suites
* (`coach-memory-section.test.tsx`; the switch in `modules-section.test.tsx`).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -78,34 +78,51 @@ function render(locale: "en" | "de" = "en") {
}
describe("<CoachSection> — SSR smoke", () => {
it("renders the section scaffold + the Activate-Coach toggle card", () => {
it("shows the Coach state with a link to the one switch, not a second switch", () => {
authState.disableCoach = false;
const html = render();
expect(html).toContain('id="settings-section-coach-title"');
expect(html).toContain('data-testid="settings-disable-coach-card"');
expect(html).toContain('data-testid="settings-disable-coach-switch"');
// v1.18.1 — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer");
// v1.18.6 — the proactive Coach nudge card moved here from
// Benachrichtigungen; its anchor renders under the enabled-coach gate.
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('data-slot="settings-coach-state-card"');
expect(html).toContain('data-enabled="true"');
expect(html).toContain('href="/settings/modules#module-coach"');
// v1.44 — no switch of its own: one state, one control, in Modules.
const card = html.match(
/data-slot="settings-coach-state-card"[\s\S]*?<\/a>/,
)?.[0];
expect(card).toBeDefined();
expect(card).not.toContain('role="switch"');
expect(html).not.toContain("settings-disable-coach");
// Raw key never leaks past i18n.
expect(html).not.toContain("settings.sections.coach.");
expect(html).not.toContain("settings.coach.activate.");
expect(html).not.toContain("settings.coach.state.");
});
it("renders the German Activate-Coach copy", () => {
authState.disableCoach = false;
it("reads off once the account has the Coach off", () => {
mountState.mounted = true;
authState.disableCoach = true;
try {
expect(render()).toContain('data-enabled="false"');
} finally {
mountState.mounted = false;
authState.disableCoach = false;
}
});
it("points the nudges at Settings > Notifications", () => {
const html = render();
expect(html).toContain('href="/settings/notifications#coach-nudge"');
expect(html).not.toContain('id="coach-nudge"');
});
it("renders the German copy", () => {
const html = render("de");
expect(html).toContain("Coach aktivieren");
expect(html).not.toContain("settings.sections.coach.");
expect(html).not.toContain("settings.coach.activate.");
expect(html).not.toContain("settings.coach.state.");
});
});
describe("<CoachSection> — the tuning follows the record, not the account", () => {
it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => {
it("hides the nudge pointer when the record has the Coach off, whatever the viewer's own opt-out says", () => {
// A guardian with the Coach on, inside a managed record that has it off:
// `/me` publishes the viewer's own `disableCoach` and the RECORD's module
// map. The record's answer decides.
@@ -113,10 +130,10 @@ describe("<CoachSection> — the tuning follows the record, not the account", ()
authState.disableCoach = false;
authState.modules = { coach: false };
try {
expect(render()).not.toContain('id="coach-nudge"');
expect(render()).not.toContain('data-slot="settings-coach-nudge-link"');
authState.disableCoach = true;
authState.modules = { coach: true };
expect(render()).toContain('id="coach-nudge"');
expect(render()).toContain('data-slot="settings-coach-nudge-link"');
} finally {
mountState.mounted = false;
authState.disableCoach = false;
@@ -0,0 +1,75 @@
/**
* One confirm style across Settings.
*
* A destructive act (delete, revoke, remove, reset, sign out) confirms through
* `ConfirmButton` / `ConfirmDialog` (`src/components/ui/confirm-button.tsx`),
* which owns the dialog's anatomy and wears the destructive style when the
* caller says the act is destructive. A hand-built `AlertDialog` is how the
* same question ended up styled three different ways on neighbouring cards.
*
* So no file under `src/components/settings` imports the raw `AlertDialog`
* unless it is listed below with the reason the primitive does not fit.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(process.cwd(), "src", "components", "settings");
const ALLOWED: Readonly<Record<string, string>> = {
"security-section/existing-factor-reauth-dialog.tsx":
"a re-authentication form, not a destructive confirm",
"security-section/passkey-list-section.tsx":
"the add-passkey re-authentication form; the delete confirm uses ConfirmButton",
"security-section/totp-card.tsx":
"the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style",
};
/** Owned by the integration cards, which follow their own card kit. */
const ALLOWED_PREFIXES: readonly string[] = ["integrations/"];
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
return name === "__tests__" ? [] : walk(path);
}
return /\.tsx?$/.test(name) ? [path] : [];
});
}
const IMPORTS_ALERT_DIALOG = /from\s+["']@\/components\/ui\/alert-dialog["']/;
describe("settings confirm style", () => {
const files = walk(ROOT);
it("scans the settings tree", () => {
expect(files.length).toBeGreaterThan(50);
});
it("imports the raw AlertDialog only where the primitive does not fit", () => {
const offenders = files
.map((path) => relative(ROOT, path))
.filter((rel) =>
IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
)
.filter(
(rel) =>
!(rel in ALLOWED) &&
!ALLOWED_PREFIXES.some((prefix) => rel.startsWith(prefix)),
);
expect(
offenders,
"Confirm a destructive act through ConfirmButton / ConfirmDialog, or list the file here with the reason",
).toEqual([]);
});
it("lists no file that no longer needs the exemption", () => {
const stale = Object.keys(ALLOWED).filter(
(rel) =>
!IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
);
expect(stale).toEqual([]);
});
});
@@ -1,155 +0,0 @@
/**
* v1.4.47 — Settings → Coach activation toggle persistence contract.
*
* v1.18.1 — the card reads "Activate Coach" (default ON). The persisted
* column is still `User.disableCoach`; the Switch shows the activated view
* (`checked = !disableCoach`). The SSR render is `mounted=false`, where the
* card paints the default-ON (checked) state so the default-on contract holds
* before the wire value resolves — both default and opted-out users render
* checked under `renderToStaticMarkup`.
*
* The card is a small client-only `<Switch>` that PATCHes
* `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so
* every Coach mount point in the app reacts on the next /me refetch.
*
* Test strategy: use the real `useMutation` + `QueryClient` from
* `@tanstack/react-query`, stub `fetch`, and drive the Switch via a
* `renderToStaticMarkup` pass plus a hand-invoked mutation call. The
* file SSR-renders inside a `<QueryClientProvider>` so the hook
* branches execute and the spies fire.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser(false), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return {
...actual,
useAuth: () => authSpy(),
};
});
// Stub the navigation hooks the parent `<CoachSection>` (and its child
// cards) reach for so the SSR pass doesn't blow up. The disable-coach card
// itself doesn't trigger any GET — it only writes through PATCH on click.
//
// v1.18.0 — the Coach cards moved from `<AiSection>` to the dedicated
// `<CoachSection>`; this contract test renders the new parent.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/coach",
useSearchParams: () => new URLSearchParams(""),
}));
function buildUser(disableCoach: boolean): AuthUser {
return {
id: "user-1",
username: "testuser",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
reportSelection: null,
cycleTrackingEnabled: false,
modules: {},
};
}
import { CoachSection } from "../coach-section";
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
JSON.stringify({
data: { disableCoach: body.disableCoach ?? false },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
});
}
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser(false),
isAuthenticated: true,
}));
vi.stubGlobal("fetch", makeFetch());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CoachSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — DisableCoachCard", () => {
it("renders the activation Switch checked at SSR (default-on contract)", () => {
const html = render();
// v1.18.1 — the SSR render is `mounted=false`, where the card paints
// the default-ON (activated → checked) state regardless of the wire value,
// so the Switch never flashes off before /me resolves.
expect(html).toContain('data-testid="settings-disable-coach-switch"');
expect(html).toContain('data-state="checked"');
});
it("paints the activated (checked) SSR state even for an opted-out user", () => {
authSpy.mockImplementation(() => ({
user: buildUser(true),
isAuthenticated: true,
}));
const html = render();
expect(html).toContain('data-testid="settings-disable-coach-switch"');
// The activated state resolves on the first client re-render once
// `mounted` flips; the SSR pass stays on the default-ON shape.
expect(html).toContain('data-state="checked"');
});
it("renders the Coach-activation title + description", () => {
const html = render();
// v1.18.1 — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer");
});
});
@@ -16,7 +16,6 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("../dashboard-section", () => ({ DashboardSection: () => null }));
vi.mock("../insights-section", () => ({ InsightsSection: () => null }));
vi.mock("../medications-section", () => ({ MedicationsSection: () => null }));
vi.mock("../mood-section", () => ({ MoodSection: () => null }));
vi.mock("../labs-section", () => ({ LabsSection: () => null }));
vi.mock("../illness-section", () => ({ IllnessSection: () => null }));
vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null }));
@@ -29,12 +28,13 @@ import {
} from "../layout-groups";
describe("layout-groups registry", () => {
it("exposes the eight Appearance modules as statically-generated subpage params", () => {
it("exposes the seven Arrangement modules as statically-generated subpage params", () => {
// v1.44 — mood left: its page was tag management, which lives under
// Settings > Modules > Mood now (`/settings/layout/mood` redirects).
expect(LAYOUT_GROUP_IDS).toEqual([
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"documents",
@@ -45,7 +45,6 @@ describe("layout-groups registry", () => {
{ module: "dashboard" },
{ module: "insights" },
{ module: "medications" },
{ module: "mood" },
{ module: "labs" },
{ module: "illness" },
{ module: "documents" },
@@ -66,7 +65,7 @@ describe("layout-groups registry", () => {
? layoutGroupModule(id)
: "not-a-group";
expect(gate("medications")).toBe("medications");
expect(gate("mood")).toBe("mood");
expect(gate("mood")).toBe("not-a-group");
expect(gate("labs")).toBe("labs");
expect(gate("illness")).toBe("illness");
// dashboard / insights / vorsorge are never gated.
@@ -54,7 +54,6 @@ describe("<LayoutSection> hub", () => {
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"vorsorge",
@@ -68,7 +67,7 @@ describe("<LayoutSection> hub", () => {
expect(html).not.toContain('href="/settings/layout/medications"');
expect(html).not.toContain('href="/settings/layout/labs"');
// Other modules unaffected; Vorsorge is never gated.
expect(html).toContain('href="/settings/layout/mood"');
expect(html).toContain('href="/settings/layout/illness"');
expect(html).toContain('href="/settings/layout/vorsorge"');
});
@@ -82,6 +81,7 @@ describe("<LayoutSection> hub", () => {
expect(html).toContain('href="/settings/layout/dashboard"');
expect(html).toContain('href="/settings/layout/insights"');
expect(html).toContain('href="/settings/layout/vorsorge"');
// v1.44 — mood is tag management and left Arrangement for Modules.
expect(html).not.toContain('href="/settings/layout/mood"');
});
@@ -137,11 +137,11 @@ describe("<MedicationsSection> — SSR smoke", () => {
expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin"));
});
it("gathers the injection-sites card (moved from the account profile)", () => {
// v1.18.0 — injection-site exclusions are a medication setting and
// now live on this screen alongside the list view + order.
it("holds order and view only; the injection sites live under Modules", () => {
// v1.44 — Arrangement holds order and view; the injection-site
// exclusions moved to Settings > Modules > Medications.
const html = render();
expect(html).toContain("Globally excluded injection sites");
expect(html).not.toContain("Globally excluded injection sites");
});
it("feeds the editor the saved order (active block re-sorted by layout.order)", () => {
@@ -131,7 +131,6 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "./section-frame-harness";
import { MoodSection } from "../mood-section";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
@@ -142,9 +141,7 @@ function render(locale: "en" | "de" = "en"): string {
// shared frame the route wraps the section in.
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<SettingsSectionFrame slug="mood">
<MoodSection />
</SettingsSectionFrame>
<MoodSection />
</I18nProvider>,
);
}
@@ -159,7 +156,8 @@ describe("<MoodSection> — SSR smoke", () => {
// v1.18.1 — the section blurb was dropped; the sr-only heading + the
// card content carry the meaning.
const html = render();
expect(html).toContain("settings-section-mood-title");
// v1.44 — the page lives under Settings > Modules > Mood; its heading
// comes from the route, not from a frame here.
expect(html).not.toContain("settings.sections.");
expect(html).not.toContain("mood.manage.");
});
@@ -121,60 +121,27 @@ describe("notification channel cards", () => {
);
it.each([
["webhook", <WebhookCard key="webhook" isAuthenticated />, 1],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />, 1],
["email", <EmailCard key="email" isAuthenticated />, 1],
["telegram", <TelegramCard key="telegram" isAuthenticated />, 0],
["webhook", <WebhookCard key="webhook" isAuthenticated />],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />],
["email", <EmailCard key="email" isAuthenticated />],
["telegram", <TelegramCard key="telegram" isAuthenticated />],
] as const)(
"%s switch invokes the enable-only mutation rather than the full save",
async (channel, card, toggleMutationIndex) => {
render(card);
await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
`/api/settings/${channel}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
},
);
},
);
it.each([
[<WebhookCard key="webhook" isAuthenticated />, 1, ["settings", "webhook"]],
[<NtfyCard key="ntfy" isAuthenticated />, 1, ["settings", "ntfy"]],
[<EmailCard key="email" isAuthenticated />, 1, ["settings", "email"]],
[
<TelegramCard key="telegram" isAuthenticated />,
0,
["telegram", "settings"],
],
] as const)(
"rolls each visible switch back to its previous server state on error",
async (card, toggleMutationIndex, queryKey) => {
const previous = { enabled: false, persistedConfig: "unchanged" };
mocks.queryClient.getQueryData.mockReturnValue(previous);
render(card);
const toggle = mocks.mutationOptions[toggleMutationIndex]!;
const context = await toggle.onMutate!(true);
const optimisticUpdater = mocks.queryClient.setQueryData.mock
.calls[0]![1] as (current: typeof previous) => typeof previous;
expect(optimisticUpdater(previous)).toEqual({
...previous,
enabled: true,
});
toggle.onError!(new Error("save failed"), true, context);
expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
queryKey,
previous,
);
"%s enable switch is a form field: it writes nothing on its own",
async (_channel, card) => {
const html = render(card);
// Rendering the switch writes nothing, and the card mounts no
// switch-only mutation: every write it can make is the full form.
expect(html).toContain('role="switch"');
expect(mocks.apiFetchRaw).not.toHaveBeenCalled();
for (const mutation of mocks.mutationOptions) {
await mutation.mutationFn(true);
}
for (const [, init] of mocks.apiFetchRaw.mock.calls as Array<
[string, { body: string }]
>) {
const body = JSON.parse(init.body) as Record<string, unknown>;
expect(Object.keys(body).length).toBeGreaterThan(1);
}
},
);
@@ -185,9 +152,9 @@ describe("notification channel cards", () => {
expect(html).toMatch(/<option value="generic" selected="">/);
expect(html).toContain('<option value="gotify">');
// The full save is the first mutation of the last render, whose closure
// holds the values seeded from the server.
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
// The full save is the mutation of the last render, whose closure holds
// the values seeded from the server, the enable switch included.
const save = mocks.mutationOptions.at(-1)!;
await save.mutationFn(false);
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
@@ -1,93 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: vi.fn(),
}));
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "../notification-channel-toggle";
beforeEach(() => {
vi.clearAllMocks();
});
describe("notification channel enable-only mutation", () => {
it.each([
"/api/settings/webhook",
"/api/settings/ntfy",
"/api/settings/email",
"/api/settings/telegram",
])("sends only enabled to %s", async (endpoint) => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
);
await persistChannelEnabled(endpoint, true, "save failed");
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
expect(
JSON.parse(vi.mocked(apiFetchRaw).mock.calls[0]![1]!.body as string),
).toEqual({ enabled: true });
});
it("changes only cached enabled state and leaves configuration fields intact", () => {
const client = new QueryClient();
const key = ["settings", "webhook"] as const;
const settings = {
enabled: false,
url: "https://saved.example/hook",
topic: "saved-topic",
recipient: "saved@example.com",
hasHeaderValue: true,
};
client.setQueryData(key, settings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(previous).toEqual(settings);
expect(client.getQueryData(key)).toEqual({ ...settings, enabled: true });
});
it("restores the exact previous cached settings after a failed toggle", () => {
const client = new QueryClient();
const key = ["settings", "telegram"] as const;
const previousSettings = {
enabled: false,
hasBotToken: true,
chatId: "123456",
};
client.setQueryData(key, previousSettings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(true);
rollbackChannelEnabled(client, key, previous);
expect(client.getQueryData(key)).toEqual(previousSettings);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(false);
});
it("surfaces the route error so the card mutation can roll back", async () => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(
JSON.stringify({ error: "Stored configuration is missing" }),
{
status: 422,
},
),
);
await expect(
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
).rejects.toThrow("Stored configuration is missing");
});
});
@@ -1,14 +1,12 @@
/**
* v1.18.0 — module-gated per-type visibility on the consolidated
* "Benachrichtigungen" reminder-types home.
* Settings > Notifications is the one home for every reminder kind, each
* card gated on its module and failing open.
*
* The mood reminder shows only when the `mood` module is enabled. Low-stock
* maps to the medications domain (shown unless explicitly disabled). The gate
* fails OPEN, so a `/me` payload without a module map keeps every card
* visible.
*
* v1.18.6 — the proactive Coach nudge card moved to Settings → Coach, so
* it no longer appears on this screen.
* v1.18.0 — mood reminder on `mood`, low-stock on `medications`.
* v1.44 — the Coach nudges came back (gated on the Coach surface), the
* checkup reminder switches joined (not a module, always shown), and a card
* names the reminders set where their subject lives (dose times on the
* medication, the cycle reminders in the per-event matrix).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -25,8 +23,9 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
// The auth mock is driven per-test via a mutable ref so each render can pick
// a different module map without re-mocking the module.
// Mounted, so the Coach gate reads the module map like a hydrated page.
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
const authRef: { modules?: Record<string, boolean> } = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -49,38 +48,43 @@ function render(modules?: Record<string, boolean>) {
);
}
describe("<NotificationsSection> module-gated reminder types", () => {
it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => {
describe("<NotificationsSection> reminder kinds", () => {
it("shows every reminder card when no module map (fails open)", () => {
const html = render(undefined);
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
// v1.18.6 — the coach nudge moved to Settings → Coach.
expect(html).not.toContain('id="coach-nudge"');
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="vorsorge-reminders"');
expect(html).toContain('data-slot="notifications-elsewhere"');
expect(html).toContain('id="cycle-reminders"');
});
it("hides the mood card when the mood module is disabled", () => {
const html = render({ mood: false });
expect(html).not.toContain('id="mood-reminder"');
// Low-stock is unaffected.
expect(html).toContain('id="low-stock"');
});
it("always shows low-stock unless medications is disabled", () => {
const html = render({ mood: false });
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="mood-reminder"');
expect(html).not.toContain('id="coach-nudge"');
it("hides the low-stock card and the dose row when medications is disabled", () => {
const html = render({ medications: false });
expect(html).not.toContain('id="low-stock"');
expect(html).not.toContain('href="/medications"');
});
it("drops the channels / inbox cross-links and the Vorsorge block (D5)", () => {
// v1.18.1 — the page is lean: no section blurb, no channels/inbox
// cross-links, and no embedded Vorsorge editor (it has its own page).
it("hides the Coach nudges when the Coach is off", () => {
expect(render({ coach: false })).not.toContain('id="coach-nudge"');
});
it("drops the elsewhere card when neither of its reminders applies", () => {
const html = render({ medications: false, cycle: false });
expect(html).not.toContain('data-slot="notifications-elsewhere"');
// The checkup reminders are not a module and stay.
expect(html).toContain('id="vorsorge-reminders"');
});
it("links to the per-event matrix and to the delivery channels", () => {
const html = render({});
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
// The remaining reminder-type cards (no coach nudge here anymore).
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="coach-nudge"');
expect(html).toContain('href="/notifications"');
expect(html).toContain('href="/settings/integrations#channels"');
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PreviewSection } from "../preview-section";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
});
if (topics !== undefined) {
client.setQueryData(queryKeys.previewFeatures(), { topics });
}
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>
<PreviewSection />
</QueryClientProvider>
</I18nProvider>,
);
}
describe("<PreviewSection>", () => {
it("shows the Topics switch off by default, saved on change", () => {
const html = render(false);
expect(html).toContain('data-slot="settings-preview-topics-switch"');
expect(html).toMatch(
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/,
);
expect(html).toContain("Topics (beta)");
// A switch, no Save: one save model per card.
expect(html).not.toContain('type="submit"');
});
it("reads on once the person switched it on", () => {
expect(render(true)).toMatch(
/data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/,
);
});
it("says the existing pages stay as they are and nothing raw leaks", () => {
const html = render(false, "de");
expect(html).toContain("Themen (Beta)");
expect(html).not.toContain("settings.preview.");
});
});
@@ -55,7 +55,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => {
it("renders the assembled sections (encryption, retention, delete)", () => {
const html = render(<PrivacySection />);
// Encryption-at-rest block driven from the seeded summary.
expect(html).toContain("AES-256-GCM");
@@ -63,15 +63,14 @@ describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
expect(html).toContain("365");
// The honest backup↔deletion lag disclosure is present.
expect(html.toLowerCase()).toContain("backup");
// Links to the existing export + destructive surfaces (assembly, not rebuild).
expect(html).toContain('href="/settings/export"');
expect(html).toContain('href="/settings/gesundheitsakte"');
// Links to the destructive surface (assembly, not rebuild).
expect(html).toContain('href="/settings/advanced"');
// Embedded active-session + security-activity cards.
expect(html).toContain("settings-security-sessions-card");
// Trusted devices now lives here (and only here) — its single home is the
// Privacy dashboard, not the Security section.
expect(html).toContain("settings-trusted-devices-card");
// v1.44 — sessions, trusted devices and the activity feed live under
// Security, and the duplicate export card is gone: Export & import and
// the Health record sit beside this page in the Data group.
expect(html).not.toContain("settings-security-sessions-card");
expect(html).not.toContain("settings-trusted-devices-card");
expect(html).not.toContain('href="/settings/export"');
});
it("shows a loading placeholder for the retention block before the summary resolves", () => {
@@ -0,0 +1,256 @@
/**
* One save model per card.
*
* A settings card either saves each control the moment it changes (switches,
* selects, segmented choices) or collects a multi-field draft behind one
* explicit Save. Never both: a card with an instant switch beside a Save
* button leaves a person guessing which of their changes the button covers,
* and the channel cards (Telegram, ntfy, webhook, email) did exactly that —
* the enable switch wrote on tap while the rest of the form waited for Save.
*
* The guard splits every settings source into card units (each
* `<SettingsCard>…</SettingsCard>` block; a file without one is a single
* unit) and fails a unit that both
* - saves explicitly: a `type="submit"` control or the `common.save` label,
* - saves instantly: a Switch / NativeSelect / Select / Segmented / Checkbox
* whose change handler does more than set local draft state.
*
* Its limit, written down: the handler check is textual. It follows calls to
* functions declared in the same file two levels deep; a write hidden behind
* a callback handed in through props, or behind an imported helper whose
* name carries none of the write markers, reads as a draft field.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const SETTINGS_DIR = join(ROOT, "src", "components", "settings");
const EXTRA_FILES = [
"src/components/labs/labs-settings.tsx",
"src/components/illness/illness-settings.tsx",
"src/components/measurement-reminders/vorsorge-settings.tsx",
];
/**
* `<file>#<card index>` → why the unit may mix both models. Empty on purpose;
* an entry needs a reason a reviewer can check.
*/
const ALLOWLIST: Readonly<Record<string, string>> = {};
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
out.push(...walk(full));
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
/** Each `<SettingsCard>` block with its nested cards counted. */
function splitCardUnits(source: string): string[] {
const open = /<SettingsCard\b/g;
const units: string[] = [];
let match: RegExpExecArray | null;
let consumedTo = 0;
while ((match = open.exec(source)) !== null) {
if (match.index < consumedTo) continue;
let depth = 0;
const token = /<SettingsCard\b|<\/SettingsCard>/g;
token.lastIndex = match.index;
let t: RegExpExecArray | null;
let end = source.length;
while ((t = token.exec(source)) !== null) {
depth += t[0] === "</SettingsCard>" ? -1 : 1;
if (depth === 0) {
end = t.index + t[0].length;
break;
}
}
units.push(source.slice(match.index, end));
consumedTo = end;
}
return units.length > 0 ? units : [source];
}
/** The JSX opening tag starting at `start`, braces and strings respected. */
function openingTag(source: string, start: number): string {
let depth = 0;
let quote: string | null = null;
for (let i = start; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === quote && source[i - 1] !== "\\") quote = null;
continue;
}
if (depth > 0 && (c === '"' || c === "'" || c === "`")) {
quote = c;
continue;
}
if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) return source.slice(start, i + 1);
}
return source.slice(start);
}
/** The `{…}` expression of `attr=` inside a tag, or null. */
function attributeExpression(tag: string, attr: string): string | null {
const at = tag.search(new RegExp(`\\b${attr}=\\{`));
if (at < 0) return null;
const open = tag.indexOf("{", at);
let depth = 0;
for (let i = open; i < tag.length; i++) {
if (tag[i] === "{") depth++;
else if (tag[i] === "}") {
depth--;
if (depth === 0) return tag.slice(open + 1, i).trim();
}
}
return null;
}
/** A call that writes to the server (or the locale) on its own. */
const WRITE_MARKER =
/\.mutate(?:Async)?\(|\bapi(?:FetchRaw|Fetch|Get|Put|Patch|Post|Delete)\(|\bsafeFetch\(|\bpersist\w*\(/;
/** The body of a function or arrow const declared in `source`, or null. */
function localDefinition(source: string, name: string): string | null {
const decl = new RegExp(
`(?:function\\s+${name}\\s*\\(|(?:const|let)\\s+${name}\\s*=)`,
).exec(source);
if (!decl) return null;
// A braceless arrow (`const f = (v) => g(v);`) ends at its line.
const rest = source.slice(decl.index);
const arrow = /^[^\n{]*=>\s*(?!\{)([^\n]*)/.exec(rest);
if (arrow) return arrow[0];
const open = source.indexOf("{", decl.index);
if (open < 0) return null;
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return source.slice(decl.index, i + 1);
}
}
return null;
}
/**
* True when a change handler writes on change: the handler itself carries a
* write call, or a function it calls that is declared in the same file does
* (followed two levels deep). A handler that only touches local state, or
* calls a callback handed in through props, reads as a draft field.
*/
function writesOnChange(handler: string, source: string, depth = 0): boolean {
if (WRITE_MARKER.test(handler)) return true;
if (depth >= 2) return false;
const names = new Set<string>();
if (/^[A-Za-z_]\w*$/.test(handler)) names.add(handler);
for (const m of handler.matchAll(/\b([A-Za-z_]\w*)\s*\(/g)) {
names.add(m[1]);
}
for (const name of names) {
const body = localDefinition(source, name);
if (body && writesOnChange(body, source, depth + 1)) return true;
}
return false;
}
const CONTROL = /<(Switch|NativeSelect|Select|Segmented|Checkbox)\b/g;
const HANDLERS = ["onCheckedChange", "onChange", "onValueChange"];
function savesInstantly(unit: string, source: string = unit): boolean {
CONTROL.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CONTROL.exec(unit)) !== null) {
const tag = openingTag(unit, match.index);
for (const attr of HANDLERS) {
const handler = attributeExpression(tag, attr);
if (handler !== null && writesOnChange(handler, source)) return true;
}
}
return false;
}
function savesExplicitly(unit: string): boolean {
return /type="submit"/.test(unit) || /t\("common\.save"\)/.test(unit);
}
function scan() {
const files = [
...walk(SETTINGS_DIR),
...EXTRA_FILES.map((f) => join(ROOT, f)),
];
const mixed: string[] = [];
let units = 0;
for (const file of files) {
const rel = relative(ROOT, file);
const source = readFileSync(file, "utf8");
splitCardUnits(source).forEach((unit, index) => {
units++;
if (savesInstantly(unit, source) && savesExplicitly(unit)) {
const key = `${rel}#${index}`;
if (!ALLOWLIST[key]) mixed.push(key);
}
});
}
return { mixed, units, files: files.length };
}
describe("settings save model", () => {
it("classifies handlers", () => {
const source = `
function handleToggle(next) { mutation.mutate(next); }
function toggleItem(kind, on) { setDraft({ kind, on }); }
const flip = (v) => handleToggle(v);
`;
expect(writesOnChange("setChatId", source)).toBe(false);
expect(writesOnChange("(e) => setUrl(e.target.value)", source)).toBe(false);
expect(writesOnChange("(v) => toggleItem('a', v)", source)).toBe(false);
expect(
writesOnChange("(checked) => toggleEnabled.mutate(checked)", source),
).toBe(true);
expect(writesOnChange("handleToggle", source)).toBe(true);
expect(writesOnChange("(v) => flip(v)", source)).toBe(true);
});
it("refuses a card that both saves on change and behind a Save button", () => {
const mixedCard = `<SettingsCard><Switch onCheckedChange={(v) => save.mutate(v)} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
const draftCard = `<SettingsCard><Switch onCheckedChange={setEnabled} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
expect(
savesInstantly(splitCardUnits(mixedCard)[0]) &&
savesExplicitly(splitCardUnits(mixedCard)[0]),
).toBe(true);
expect(savesInstantly(splitCardUnits(draftCard)[0])).toBe(false);
});
it("finds no card that mixes instant and explicit saving", () => {
const { mixed, units, files } = scan();
// An empty scan would pass for the wrong reason.
expect(files).toBeGreaterThan(50);
expect(units).toBeGreaterThan(100);
expect(
mixed,
`cards mixing both save models:\n${mixed.join("\n")}`,
).toEqual([]);
});
it("keeps every allowlist entry pointing at a unit that still mixes", () => {
for (const key of Object.keys(ALLOWLIST)) {
const [file, index] = key.split("#");
const source = readFileSync(join(ROOT, file), "utf8");
const unit = splitCardUnits(source)[Number(index)];
expect(unit, key).toBeDefined();
expect(savesInstantly(unit, source) && savesExplicitly(unit), key).toBe(
true,
);
}
});
});
@@ -246,7 +246,8 @@ describe("settings sections — SSR smoke", () => {
// card is gone.
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="coach-nudge"');
// v1.44 — the Coach nudges are back with the other reminders.
expect(html).toContain('id="coach-nudge"');
// v1.25.7 — delivery channels moved to Settings → Integrationen, so the
// channels group + panel no longer render on this surface.
expect(html).not.toContain('id="channels"');
@@ -11,6 +11,7 @@ import { join } from "node:path";
const mockPathnameRef = { value: "/settings/account" };
vi.mock("next/navigation", () => ({
usePathname: () => mockPathnameRef.value,
useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
// v1.18.0 — the shell module-gates per-submodule nav entries off
@@ -169,6 +170,8 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one pane, after `advanced`.
"privacy",
// v1.44 — per-user preview opt-ins, in the System group before About.
"preview",
"about",
]);
});
@@ -193,23 +196,59 @@ describe("SETTINGS_SECTION_SLUGS", () => {
});
describe("<SettingsShell>", () => {
it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => {
it("renders the nine groups as non-interactive sidebar headers, in order", () => {
const html = renderShell({ active: "account" });
for (const label of [
const labels = [
"Account",
"Your data",
"Display",
"Tracking",
"Arrangement",
"Notifications",
"Health profile",
"AI",
"Connectivity",
"Connections",
"Data",
"System",
]) {
expect(html).toContain(`>${label}</li>`);
];
let cursor = -1;
for (const label of labels) {
const at = html.indexOf(`>${label}</li>`);
expect(at, label).toBeGreaterThan(cursor);
cursor = at;
}
// Seven groups produce six quiet separators in the mobile strip.
expect(
html.match(/aria-hidden="true"/g)?.length ?? 0,
).toBeGreaterThanOrEqual(6);
});
it("puts every section in the group the plan names", () => {
const groupOf = Object.fromEntries(
SETTINGS_SECTIONS.map((section) => [section.slug, section.group]),
);
expect(groupOf).toEqual({
account: "account",
security: "account",
access: "account",
modules: "tracking",
layout: "arrangement",
notifications: "notifications",
score: "healthProfile",
thresholds: "healthProfile",
anamnesis: "healthProfile",
environment: "healthProfile",
ai: "ai",
coach: "ai",
integrations: "connections",
sources: "connections",
api: "connections",
mcp: "connections",
export: "data",
gesundheitsakte: "data",
privacy: "data",
advanced: "data",
preview: "system",
about: "system",
});
// Every routable section has a nav entry, and nothing else does.
expect([...SETTINGS_SECTIONS.map((s) => s.slug)].sort()).toEqual(
[...SETTINGS_SECTION_SLUGS].sort(),
);
});
it("starts the sticky desktop sidebar on the heading row", () => {
@@ -230,19 +269,14 @@ describe("<SettingsShell>", () => {
expect(source).not.toMatch(/<aside[\s\S]*?md:row-start-2/);
});
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
it("renders every navigable section link in the sidebar", () => {
const html = renderShell({ active: "account" });
// Every slug — including `about`, which returned to the shell nav
// as the last entry after living dropdown-only since v1.4.33 IW7 —
// renders in both layouts.
// The phone's section sheet mounts its list only when opened, so the
// server markup carries each link once, in the desktop sidebar.
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
for (const slug of navigableSlugs) {
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
// Two renders — mobile strip + desktop sidebar — guarantee the link
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
// but the markup is always present so it's keyboard-discoverable
// before media queries resolve.
expect(matches?.length ?? 0).toBe(2);
expect(matches?.length ?? 0).toBe(1);
}
});
@@ -259,9 +293,9 @@ describe("<SettingsShell>", () => {
it('marks the active section with aria-current="page" (and only that one)', () => {
const html = renderShell({ active: "notifications" });
// Both layouts emit the active link, so two `aria-current="page"`.
// The sidebar marks the active link; the phone sheet is closed.
const occurrences = html.match(/aria-current="page"/g);
expect(occurrences?.length ?? 0).toBe(2);
expect(occurrences?.length ?? 0).toBe(1);
// And the active link is on the notifications href. Attribute order
// in React SSR is alphabetic, so `aria-current` precedes `href` —
@@ -269,28 +303,38 @@ describe("<SettingsShell>", () => {
// future React renderer changes.
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
});
it("derives the active slug from the current pathname when `active` is omitted", () => {
const html = renderShell({ pathname: "/settings/api" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
});
it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => {
// On `/settings/dashboard` (a Layout child reached through the hub) the
// Layout nav entry must read active even though Dashboard has no nav
// entry of its own. Both layouts emit the active link → two matches.
for (const child of ["/settings/dashboard", "/settings/insights"]) {
it("highlights the Arrangement hub on a legacy child path and on its sub-pages", () => {
// `/settings/dashboard` (a redirected legacy child) and the sub-pages
// under `/settings/layout/<id>` both read as the Arrangement entry.
for (const child of [
"/settings/dashboard",
"/settings/insights",
"/settings/layout/labs",
]) {
const html = renderShell({ pathname: child });
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
expect(html.match(layoutActive)?.length ?? 0).toBe(1);
}
});
it("highlights Modules on a module's own settings page", () => {
const html = renderShell({ pathname: "/settings/modules/labs" });
const modulesActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/modules"|<a\b[^>]*\bhref="\/settings\/modules"[^>]*\baria-current="page"/g;
expect(html.match(modulesActive)?.length ?? 0).toBe(1);
});
// v1.37.19 — the Layout children left `SETTINGS_SECTION_SLUGS`, so no
// page can pass one as the `active` override anymore; the pathname-based
// case above is the remaining (defensive) mapping.
@@ -424,7 +468,7 @@ describe("<SettingsShell>", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1);
});
it("resolves every section title via the i18n provider — English", () => {
@@ -440,7 +484,8 @@ describe("<SettingsShell>", () => {
// "Layout": it arranges and picks what each module shows and holds no
// theme, so "Appearance" promised a control it does not have.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Layout</a>");
// v1.44 — the hub holds only order and view, and says so.
expect(html).toContain(">Arrangement</a>");
// v1.25.3 — Channels folded into Notifications, so it is no longer a
// left-side entry; Sources keeps its own.
expect(html).not.toContain('href="/settings/channels"');
@@ -484,7 +529,7 @@ describe("<SettingsShell>", () => {
// (route stays `/settings/layout`). v1.42 — "Layout" in German too;
// "Darstellung" now names the account page's language-and-units card.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Layout</a>");
expect(html).toContain(">Anordnung</a>");
// v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps
// its own left-side entry.
expect(html).not.toContain('href="/settings/channels"');
@@ -512,17 +557,14 @@ describe("<SettingsShell>", () => {
expect(html).not.toContain("settings.sections.");
});
it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
// The 10-section strip is wider than 393 CSS px, so without
// `no-scrollbar` Chromium/WebKit paints an always-on horizontal
// scrollbar at the top of every settings page. The class is
// defined in `globals.css` and combines `scrollbar-width: none`
// (Firefox) with `::-webkit-scrollbar { display: none }` (everyone
// else) — scroll behaviour is preserved.
const html = renderShell({ active: "account" });
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>/);
it("gives the phone one button naming the section, not a strip of every section", () => {
const html = renderShell({ active: "security" });
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>[\s\S]*?<\/nav>/);
expect(nav).not.toBeNull();
expect(nav![0]).toMatch(/\bno-scrollbar\b/);
expect(nav![0]).toMatch(/\boverflow-x-auto\b/);
expect(nav![0]).toContain('data-slot="settings-mobile-nav-trigger"');
expect(nav![0]).toContain("Security");
expect(nav![0]).toContain("Account");
expect(nav![0]).not.toContain("overflow-x-auto");
expect(nav![0]).not.toContain('href="/settings/');
});
});
@@ -26,10 +26,14 @@ function read(rel: string): string {
return readFileSync(join(process.cwd(), rel), "utf8");
}
/** The body of every `<AlertDialogAction …>…</AlertDialogAction>` block. */
/**
* Every confirm the source spells: an `<AlertDialogAction …>…</AlertDialogAction>`
* block, or a `<ConfirmButton … />` whose `confirmLabel` is the action label.
*/
function actions(source: string): string[] {
return [
...source.matchAll(/<AlertDialogAction\b[\s\S]*?<\/AlertDialogAction>/g),
...source.matchAll(/<ConfirmButton\b[\s\S]*?\/>/g),
].map((m) => m[0]);
}
@@ -41,7 +45,9 @@ describe("token revoke labels", () => {
);
expect(revokes.length).toBeGreaterThan(0);
for (const action of revokes) {
expect(action).toContain('t("settings.tokenRevokeAction")');
expect(action).toMatch(
/(confirmLabel=\{|>\s*\{)t\("settings\.tokenRevokeAction"\)/,
);
}
});
@@ -44,10 +44,15 @@ describe("Settings → Account display card", () => {
"GlucoseUnitSelect",
"TimeFormatSelect",
"DateFormatSelect",
// v1.44 — the timezone saves on change too, in the display card.
"TimezoneSelect",
"TimezonePicker",
]) {
expect(form).not.toContain(control);
}
// The timezone saves with the form, so it stays inside it.
expect(form).toContain("TimezonePicker");
const card = src.slice(
src.indexOf("export function DisplayPreferencesCard"),
);
expect(card).toContain("<TimezoneSelect");
});
});
@@ -18,9 +18,9 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
* browser actually IS in Berlin, leave it alone — the picker
* stays on Berlin and the next save is a no-op.
* - If the stored value is the default AND the browser reports a
* non-Berlin zone, pre-fill the picker with the detected zone.
* The form's existing submit handler persists the change on the
* next save; no toast, no banner, no opt-in.
* non-Berlin zone, that zone is the suggestion. Since v1.44 the
* timezone saves on change in the Display card, which offers the
* suggestion as a one-tap action under the select.
*
* The bootstrap deliberately runs inline during render (the strict
* `react-hooks/set-state-in-effect` rule outlaws setState in an
@@ -28,17 +28,12 @@ import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { storeTimezone } from "@/lib/timezone-mirror";
import { TimezoneSelect } from "@/components/settings/timezone-select";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import {
detectBrowserTimezone,
DEFAULT_TIMEZONE,
userDayKey,
} from "@/lib/tz/format";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -52,7 +47,6 @@ import {
import { FieldError } from "@/components/forms/field-error";
import {
clearRejectedField,
resolveInitialTimezone,
statusText,
type StatusMessage,
} from "./account-section-utils";
@@ -95,7 +89,6 @@ export function AccountSection() {
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>(DEFAULT_TIMEZONE);
// The name the app addresses the account by; set in the setup flow, and
// until now not editable anywhere after it.
const [displayName, setDisplayName] = useState("");
@@ -109,7 +102,6 @@ export function AccountSection() {
height: EMPTY_HEIGHT_DRAFT,
dateOfBirth: "",
gender: "",
timezone: DEFAULT_TIMEZONE,
fullName: "",
insurerName: "",
insuranceNumber: "",
@@ -160,7 +152,6 @@ export function AccountSection() {
? dateOnlyKey(new Date(user.dateOfBirth))
: "",
gender: user.gender ?? "",
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
fullName: user.fullName ?? "",
insurerName: user.insurerName ?? "",
insuranceNumber: user.insuranceNumber ?? "",
@@ -171,7 +162,6 @@ export function AccountSection() {
setHeight(seed.height);
setDateOfBirth(seed.dateOfBirth);
setGender(seed.gender);
setTimezone(seed.timezone);
setFullName(seed.fullName);
setInsurerName(seed.insurerName);
setInsuranceNumber(seed.insuranceNumber);
@@ -203,7 +193,6 @@ export function AccountSection() {
height,
dateOfBirth,
gender,
timezone,
fullName,
insurerName,
insuranceNumber,
@@ -268,55 +257,36 @@ export function AccountSection() {
height,
dateOfBirth,
gender,
timezone,
fullName: fullName.trim(),
insurerName: insurerName.trim(),
insuranceNumber: insuranceNumber.trim(),
};
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
// through the bigger profile patch path. Run the two PUTs in
// parallel — they're independent.
const [profileRes, tzRes] = await Promise.all([
apiFetchRaw("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
// An emptied field clears the name; the greeting falls back to
// the username, as it does for an account that never set one.
displayName: savedProfile.displayName || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
}),
// v1.44 — the timezone left this form: it saves on change in the
// Display card (`TimezoneSelect`), like the other display preferences.
const profileRes = await apiFetchRaw("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
// An emptied field clears the name; the greeting falls back to
// the username, as it does for an account that never set one.
displayName: savedProfile.displayName || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
}),
user && timezone && timezone !== user.timezone
? apiFetchRaw("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone }),
})
: Promise.resolve({ ok: true } as Response),
]);
});
setEmailPassword("");
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
// the same synchronous-update pattern the time/date-format selects
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
if (user && timezone && timezone !== user.timezone) {
storeTimezone(timezone);
}
if (profileRes.ok) {
const profileJson = (await profileRes.json().catch(() => null)) as {
data?: { rejectedFields?: RejectedProfileField[] };
} | null;
@@ -351,22 +321,6 @@ export function AccountSection() {
setProfileSeed(savedProfile);
await refetch();
}
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
// text. Surface its message verbatim so the user sees
// "Not a valid IANA timezone." instead of the generic save
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
setSaveMsg(
json.error
? { text: json.error }
: { key: "settings.timezoneInvalid" },
);
} catch {
setSaveMsg({ key: "settings.timezoneInvalid" });
}
setSaveMsgType("error");
} else {
// The server's top-level `error` string is intentionally generic
// (never a raw validator message); resolve the specific, localized
@@ -584,10 +538,9 @@ export function AccountSection() {
</div>
</div>
{/* Date of birth + timezone share one paired grid row. The
timezone stays on this card because it saves with the form
(its own PUT runs beside the profile PUT); the display
preferences that save on change live in their own card below. */}
{/* Date of birth shares the grid of the fields above. The timezone
moved to the Display card below in v1.44: it saves on change
there, like the other display preferences. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
@@ -609,7 +562,6 @@ export function AccountSection() {
</p>
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
</div>
<TimezonePicker value={timezone} onChange={setTimezone} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -817,6 +769,7 @@ export function DisplayPreferencesCard({
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
<TimezoneSelect isAuthenticated={isAuthenticated} />
</div>
</SettingsCard>
);
+56 -114
View File
@@ -4,17 +4,7 @@ import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -137,62 +127,38 @@ function DataResetCard() {
</p>
)}
<SettingsCardActions>
<AlertDialog
<Button
type="button"
onClick={() => setConfirmOpen(true)}
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
// Hold the dialog open while the request runs so the pending
// state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title={t("settings.dangerZoneConfirm")}
body={t("settings.dangerZoneConfirmDescription")}
confirmLabel={t("settings.finalDelete")}
onConfirm={() => void handleDeleteAllData()}
pending={deleting}
slot="settings-data-reset"
holdOpen
destructive
/>
</SettingsCardActions>
</SettingsCard>
);
@@ -291,63 +257,39 @@ function AccountDeleteCard() {
</p>
)}
<SettingsCardActions>
<AlertDialog
<Button
type="button"
onClick={() => setConfirmOpen(true)}
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
// Hold the dialog open while the request runs so the pending
// state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title={t("settings.deleteAccountConfirmTitle")}
body={t("settings.deleteAccountConfirmDescription")}
confirmLabel={t("settings.deleteAccountFinal")}
onConfirm={() => void handleDeleteAccount()}
pending={deleting}
slot="settings-account-delete"
holdOpen
destructive
/>
</SettingsCardActions>
</SettingsCard>
);
@@ -1,182 +0,0 @@
"use client";
/**
* v1.4.47 — Coach activation toggle card.
*
* v1.18.0 — moved out of Settings → AI into the dedicated Coach
* section; its `<section>` id namespace is `settings-coach-activate-*`
* accordingly. (The file path stays under `ai/` as pre-existing drift.)
*
* v1.18.1 — polarity flip. The card now reads "Activate Coach" with the
* Switch ON by default (the Coach is on for everyone unless the user opts
* out). The persisted state is still the single `User.disableCoach` field —
* one source of truth — so the Switch's `checked` is `!disableCoach` and a
* flip writes `disableCoach: !next`. Presenting it as activate/default-on is
* a pure UI inversion over the same column.
*
* Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates
* `aiInputDependentKeys` (the account payload first) so every Coach mount point on the client
* (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline `<CoachLaunchButton>`
* pill, the `/targets` page CTAs) re-renders with the new `user.disableCoach`
* value on the next React Query refetch tick — no full reload required.
*
* The optimistic-update pattern mirrors `<MoodReminderCard>`: the Switch
* flips immediately, the mutation rolls back on error.
*
* v1.16.4 — header follows the shared `SettingsCardHeader` contract (icon
* column + title + switch in the status slot) so the card reads like its
* siblings instead of carrying its own bespoke header row.
*/
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { MessageCircleHeart } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiPatch } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
export function DisableCoachCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
// Optimistic-update flag — null means "no in-flight change",
// otherwise it overrides the wire value until the mutation
// settles. Mirrors the `<MoodReminderCard>` pattern so the Switch
// reacts instantly to the user's tap.
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.48 — auto-clear the inline `<p role="status">` line 3 s
// after the mutation settles so a Settings card the user scrolled
// past minutes earlier doesn't keep echoing a stale "Coach hidden"
// banner. The ref tracks the in-flight timer so we can clear it on
// unmount + on a follow-up toggle (otherwise a rapid double-tap
// could leave a stray timer pointing at a stale message).
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
// `mounted`-gated: `user` comes from the auth query, which can resolve
// before this boundary hydrates — the Switch state must match the SSR HTML
// during hydration (React #418) and pick up the wire value on the first
// client re-render. v1.18.1 (D7) — the toggle now reads "Coach aktivieren"
// (default ON), so the Switch is checked when the Coach is ACTIVE. The
// persisted column is still `disableCoach`; `optimistic` and `checked` are
// the activated-state view of it. The SSR default is ON (checked) so the
// default-on contract holds before the wire value resolves.
const mounted = useMounted();
const activated = mounted
? (optimistic ?? !(user?.disableCoach ?? false))
: true;
const mutation = useMutation({
// `next` is the desired ACTIVATED state; the column stores the inverse.
mutationFn: async (next: boolean) => {
await apiPatch("/api/auth/me/disable-coach", { disableCoach: !next });
return next;
},
onSuccess: (next) => {
setMsg(
next
? t("settings.coach.activate.savedOn")
: t("settings.coach.activate.savedOff"),
);
setMsgType("success");
// Surface the new value to every Coach gate via the shared
// `useAuth()` query — the gates re-render once React Query
// re-fetches /api/auth/me with the updated `disableCoach` field.
void invalidateKeys(queryClient, aiInputDependentKeys);
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.coach.activate.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
function handleToggle(next: boolean) {
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
return (
<SettingsCard
as="section"
aria-labelledby="settings-coach-activate-title"
data-testid="settings-disable-coach-card"
>
<SettingsCardHeader
anchor="coach-activate"
icon={MessageCircleHeart}
title={t("settings.coach.activate.title")}
titleId="settings-coach-activate-title"
description={t("settings.coach.activate.description")}
status={
<Switch
data-testid="settings-disable-coach-switch"
checked={activated}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || mutation.isPending}
aria-label={t("settings.coach.activate.toggleAria")}
/>
}
/>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-xs"
: "text-muted-foreground mt-3 text-xs"
}
>
{msg}
</p>
)}
</SettingsCard>
);
}
@@ -18,18 +18,8 @@ import {
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -246,30 +236,17 @@ export function FallbackChainCard({
</p>
)}
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="min-h-11 sm:min-h-9">
<RotateCcw className="h-4 w-4" />
{t("settings.ai.providerChain.resetDefaults")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.providerChain.resetConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.providerChain.resetConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={resetToDefaults}>
{t("settings.ai.providerChain.resetDefaults")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.ai.providerChain.resetDefaults")}
title={t("settings.ai.providerChain.resetConfirmTitle")}
body={t("settings.ai.providerChain.resetConfirmBody")}
confirmLabel={t("settings.ai.providerChain.resetDefaults")}
onConfirm={() => void resetToDefaults()}
size="sm"
className="min-h-11 sm:min-h-9"
icon={<RotateCcw className="h-4 w-4" />}
destructive
/>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
@@ -41,6 +41,7 @@ import { EmergencyProfileManager } from "@/components/records/emergency-profile-
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager";
import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager";
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
export function AnamnesisSection() {
const { t } = useTranslations();
@@ -112,6 +113,11 @@ export function AnamnesisSection() {
<LifeCircumstancesManager />
</SettingsCard>
{/* v1.43 (#614) — screening hints from the life circumstances, with
their source. v1.44 — beside the circumstances they come from
(they sat on the checkups arrangement page before). */}
<ScreeningHintsCard />
<SettingsCard>
<SettingsCardHeader
anchor="allergies"
+25 -77
View File
@@ -17,19 +17,9 @@ import {
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -591,39 +581,19 @@ function ApiTokensCard() {
: t("settings.tokenNeverUsed")}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-9 w-9"
aria-label={t("settings.tokenRevokeAction")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => handleRevoke(tok.id)}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-9 w-9"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</td>
</tr>
);
@@ -690,39 +660,17 @@ function ApiTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevokeAction")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => handleRevoke(tok.id)}
size="sm"
className="min-h-11 w-full"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
);
})}
+35 -11
View File
@@ -7,16 +7,29 @@
* note left over from an earlier save would say the opposite of the truth.
* A secret field (header value, auth token, bot token) is write-only: the
* server never returns it, so any typed value counts as a change.
*
* The enable switch is a field of the same form: it saves with the one Save
* button like every other field, so a flipped switch is a change too.
*/
function enabledChanged(
form: { enabled: boolean },
saved: { enabled?: boolean },
): boolean {
return form.enabled !== (saved.enabled ?? false);
}
export function isWebhookFormDirty(
form: {
url: string;
headerName: string;
headerValue: string;
format: "generic" | "gotify";
enabled: boolean;
},
saved: { url: string; headerName: string; format?: string } | undefined,
saved:
| { url: string; headerName: string; format?: string; enabled?: boolean }
| undefined,
): boolean {
if (!saved) return false;
const savedFormat = saved.format === "gotify" ? "gotify" : "generic";
@@ -24,34 +37,45 @@ export function isWebhookFormDirty(
form.url !== saved.url ||
form.headerName !== saved.headerName ||
form.headerValue !== "" ||
form.format !== savedFormat
form.format !== savedFormat ||
enabledChanged(form, saved)
);
}
export function isNtfyFormDirty(
form: { serverUrl: string; topic: string; authToken: string },
saved: { serverUrl: string; topic: string } | undefined,
form: {
serverUrl: string;
topic: string;
authToken: string;
enabled: boolean;
},
saved: { serverUrl: string; topic: string; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
return (
form.serverUrl !== saved.serverUrl ||
form.topic !== saved.topic ||
form.authToken !== ""
form.authToken !== "" ||
enabledChanged(form, saved)
);
}
export function isEmailFormDirty(
form: { recipient: string },
saved: { recipient: string } | undefined,
form: { recipient: string; enabled: boolean },
saved: { recipient: string; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
return form.recipient !== saved.recipient;
return form.recipient !== saved.recipient || enabledChanged(form, saved);
}
export function isTelegramFormDirty(
form: { botToken: string; chatId: string },
saved: { chatId?: string | null } | undefined,
form: { botToken: string; chatId: string; enabled: boolean },
saved: { chatId?: string | null; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
return form.botToken.trim() !== "" || form.chatId !== (saved.chatId ?? "");
return (
form.botToken.trim() !== "" ||
form.chatId !== (saved.chatId ?? "") ||
enabledChanged(form, saved)
);
}
@@ -34,17 +34,6 @@ import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Brain, Loader2, Pencil, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -375,54 +364,20 @@ export function CoachMemorySection({
</p>
<SettingsCardActions>
{facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.ai.coachMemory.forgetAll")}
title={t("settings.ai.coachMemory.forgetAllConfirmTitle")}
body={t("settings.ai.coachMemory.forgetAllConfirmBody")}
confirmLabel={t("settings.ai.coachMemory.forgetAllConfirmAction")}
onConfirm={() => forgetAll.mutate()}
pending={forgetAll.isPending}
disabled={!isAuthenticated}
size="sm"
className="min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
testId="settings-coach-memory-forget-all"
destructive
/>
) : null}
</SettingsCardActions>
</SettingsCard>
+48 -14
View File
@@ -7,13 +7,14 @@
* their own dedicated entry so the AI section owns only provider / model /
* BYOK configuration. This section gathers the Coach-specific surface:
*
* 1. Disable Coach — the per-user opt-out (kept first so a user who
* wants the surface gone finds it immediately).
* 1. Coach state — on or off, with a link to the one switch in
* Settings > Modules (v1.44: the second switch that
* used to sit here is gone; kept first so a person
* who wants the surface gone finds the way at once).
* 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`).
* 3. Proactive nudge — the daily-nudge opt-out + per-group frequency
* (moved here from Settings → Benachrichtigungen in
* v1.18.6: it is a Coach setting, not a generic
* notification).
* 3. Proactive nudges — a pointer to Settings > Notifications, where the
* nudge card lives with the other reminders since
* v1.44 (`#coach-nudge` forwards there).
* 4. Coach memory — durable-fact review + forget controls.
*
* The nav entry is module-gated on `coach`. The preference + nudge cards
@@ -34,19 +35,30 @@
* `<SettingsSectionFrame>` in the route; this body is the Coach cards only.
*/
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import Link from "next/link";
import { BellRing, ChevronRight, MessageCircleHeart } from "lucide-react";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { ModuleStateCard } from "@/components/settings/module-state-card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useModuleEnabledState } from "@/hooks/use-module-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible } from "@/lib/modules/surface";
export function CoachSection() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
// The same reading the Modules switch shows, so both say one thing.
const moduleSwitchState = useModuleEnabledState();
// Mirror the AI section's hydration-safe gating: the auth query can
// resolve before this boundary hydrates, so gate the child props on
// `useMounted()` to keep the hydration render matching the SSR HTML.
@@ -62,16 +74,38 @@ export function CoachSection() {
return (
<div className="space-y-6">
<DisableCoachCard isAuthenticated={authed} />
{/* Before hydration the state reads on, the default, so the server
render and the first client paint agree. */}
<ModuleStateCard
moduleKey="coach"
enabled={!mounted || moduleSwitchState("coach")}
icon={MessageCircleHeart}
title={t("settings.coach.state.title")}
description={t("settings.coach.state.description")}
anchor="coach-activate"
slot="settings-coach-state-card"
/>
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
{/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6).
Keeps the `#coach-nudge` anchor so existing deep links resolve. */}
{/* The proactive nudges are a reminder: set under Notifications with
the others since v1.44. */}
{coachEnabled && (
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
<SettingsCard data-slot="settings-coach-nudge-link">
<SettingsCardHeader
icon={BellRing}
title={t("notifications.coachNudge.title")}
description={t("settings.coach.nudgesMoved")}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href="/settings/notifications#coach-nudge">
{t("settings.coach.openNudges")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
)}
<CoachMemorySection isAuthenticated={authed} />
@@ -765,6 +765,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
destructive
/>
{dirty && (
<>
+8 -49
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -39,30 +42,6 @@ export function DateFormatSelect({
const [optimistic, setOptimistic] = useState<DateFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO";
const mutation = useMutation({
@@ -84,31 +63,19 @@ export function DateFormatSelect({
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeDateFormat(next);
setMsg(t("settings.dateFormat.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dateFormat.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error ? err.message : t("settings.dateFormat.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dateFormat.saveError"), { cause: err });
},
});
function handleSelect(next: DateFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -128,16 +95,8 @@ export function DateFormatSelect({
</option>
))}
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dateFormat.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dateFormat.description")}
</p>
</div>
);
+23 -60
View File
@@ -13,11 +13,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -33,10 +30,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [recipient, setRecipient] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsEmail(),
@@ -47,16 +43,19 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings ? settings.recipient : null;
const settingsKey = settings
? `${settings.recipient}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey !== null && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setRecipient(settings!.recipient);
setEnabled(settings!.enabled);
}
const dirty = isEmailFormDirty({ recipient }, settings);
const dirty = isEmailFormDirty({ recipient, enabled }, settings);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/email", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -69,45 +68,12 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
EmailSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/email", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsEmail();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<EmailSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -128,16 +94,16 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -156,12 +122,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -173,7 +136,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
+10 -51
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -42,30 +45,6 @@ export function GlucoseUnitSelect({
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<GlucoseUnit | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: GlucoseUnit =
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
@@ -85,36 +64,24 @@ export function GlucoseUnitSelect({
return next;
},
onSuccess: () => {
setMsg(t("settings.dashboard.glucoseUnit.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dashboard.glucoseUnit.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userGlucoseUnit(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.glucoseUnit.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dashboard.glucoseUnit.saveError"), {
cause: err,
});
},
});
function handleSelect(next: GlucoseUnit) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -134,16 +101,8 @@ export function GlucoseUnitSelect({
<option value="mg/dL">mg/dL</option>
<option value="mmol/L">mmol/L</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.glucoseUnit.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dashboard.glucoseUnit.description")}
</p>
</div>
);
@@ -74,6 +74,7 @@ export function InjectionSitesCard({
return (
<SettingsCard>
<SettingsCardHeader
anchor="injection-sites"
icon={Syringe}
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
+5 -7
View File
@@ -5,7 +5,6 @@ import { surfaceModule } from "@/lib/modules/surface";
import { DashboardSection } from "./dashboard-section";
import { InsightsSection } from "./insights-section";
import { MedicationsSection } from "./medications-section";
import { MoodSection } from "./mood-section";
import { LabsSection } from "./labs-section";
import { IllnessSection } from "./illness-section";
import { DocumentsSection } from "./documents-section";
@@ -30,6 +29,11 @@ import { VorsorgeSection } from "./vorsorge-section";
* usable on the server. It holds references to the `"use client"` section
* components, which a server component renders as ordinary client boundaries.
*
* v1.44 — the hub holds order and view only ("Arrangement"). The mood tag
* management, the labs biomarker catalog and text recognition, and the
* injection sites moved to the module pages under Settings > Modules
* (`module-settings-pages.tsx`); `/settings/layout/mood` redirects there.
*
* Each module section keeps its module-gate: a disabled module's row / subpage
* does not render, read from the resolved `useAuth().user.modules` map (the
* same map the nav, the Modules hub, and the Insights pills gate off). The gate
@@ -76,12 +80,6 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
descriptionKey: "settings.sections.layout.medications.description",
Body: MedicationsSection,
},
{
id: "mood",
titleKey: "settings.sections.layout.mood.title",
descriptionKey: "settings.sections.layout.mood.description",
Body: MoodSection,
},
{
id: "labs",
titleKey: "settings.sections.layout.labs.title",
+55 -130
View File
@@ -17,22 +17,12 @@
* least-privilege read scope and nothing more.
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -42,15 +32,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ModuleStateCard } from "@/components/settings/module-state-card";
import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -169,39 +156,17 @@ function McpConnectionsCard() {
? formatDateTime(conn.lastUsedAt)
: t("settings.mcp.connectionNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.mcp.connectionRevoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.mcp.connectionRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.mcp.connectionRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(conn.id)}
>
{t("settings.mcp.connectionRevoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.mcp.connectionRevoke")}
className="min-h-11 w-full"
title={t("settings.mcp.connectionRevoke")}
body={t("settings.mcp.connectionRevokeDescription")}
confirmLabel={t("settings.mcp.connectionRevoke")}
onConfirm={() => void handleRevoke(conn.id)}
slot="mcp-connection-revoke"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
))}
</ul>
@@ -211,10 +176,16 @@ function McpConnectionsCard() {
);
}
/**
* The connector's state, with a link to the one switch in Settings > Modules.
* Turning the module off pauses the endpoint: it refuses every call, while the
* tokens and connections below stay as they are and work again once it is on.
* Nothing here or in Modules revokes a token as a side effect (#1170); a token
* goes only through its own revoke.
*/
function McpEnableCard() {
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
// The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is
// the resolved state (true only when the user turned it on).
@@ -222,53 +193,29 @@ function McpEnableCard() {
const endpoint =
typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp";
const toggle = useMutation({
mutationKey: queryKeys.modulesPrefs(),
mutationFn: async (next: boolean) =>
apiPatch("/api/auth/me/modules", { mcp: next }),
onSuccess: () => {
void invalidateKeys(queryClient, aiInputDependentKeys);
toast.success(t("settings.sections.modules.saved"));
},
onError: (failure) =>
showErrorToast(t("settings.sections.modules.error"), { cause: failure }),
});
return (
<SettingsCard>
<SettingsCardHeader
anchor="mcp-enable"
icon={Plug}
title={t("settings.mcp.enableTitle")}
description={t("settings.mcp.enableDescription")}
/>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{t("settings.mcp.enableToggleLabel")}
</span>
<Switch
checked={enabled}
disabled={toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-label={t("settings.mcp.enableToggleLabel")}
/>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
{t("settings.mcp.endpointLabel")}
</p>
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
{endpoint}
</code>
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
{t("settings.mcp.connectDescription")}
</p>
</div>
<ModuleStateCard
moduleKey="mcp"
enabled={enabled}
icon={Plug}
title={t("settings.mcp.enableTitle")}
description={t("settings.mcp.enableDescription")}
anchor="mcp-enable"
slot="settings-mcp-state-card"
>
<p className="text-sm">{t("settings.mcp.offKeepsTokens")}</p>
<div>
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
{t("settings.mcp.endpointLabel")}
</p>
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
{endpoint}
</code>
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
{t("settings.mcp.connectDescription")}
</p>
</div>
</SettingsCard>
</ModuleStateCard>
);
}
@@ -508,39 +455,17 @@ function McpTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevokeAction")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.tokenRevokeAction")}
className="min-h-11 w-full"
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => void handleRevoke(tok.id)}
slot="mcp-token-revoke"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
);
})}
@@ -11,8 +11,6 @@ import { MedicationViewToggle } from "@/components/medications/medication-view-t
import { SettingsCard } from "@/components/settings/settings-card";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
@@ -24,7 +22,7 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou
* from a Layout-hub child to its own standalone nav entry and gathered the
* medication-specific preferences here. v1.18.1 (D3) made medications a
* toggleable fail-open module, so this nav entry hides when the account
* turns the module off (the medication data routes stay live). Hosts three
* turns the module off (the medication data routes stay live). Hosts two
* blocks:
* 1. The list VIEW preference — cards vs table, written optimistically
* through the same `PUT /api/medications/layout` the page header
@@ -32,8 +30,9 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou
* 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv)
* with drag + arrow reordering, flushed by an explicit Save,
* persisted as active-ids-then-inactive-ids on the same layout row.
* 3. Injection-site exclusions — moved here from the account profile so
* every medication-specific preference lives on one screen.
*
* v1.44 — Arrangement holds order and view only; the injection-site
* exclusions moved to the medications page under Modules.
*
* The list view + order both write through the same `/api/medications/layout`
* contract the /medications page reads, so a save here repaints both views.
@@ -49,7 +48,6 @@ interface MedicationListEntry {
export function MedicationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { layout, isLayoutLoading, setView } = useMedicationListLayout();
const { data: medications, isLoading } = useQuery({
@@ -120,11 +118,6 @@ export function MedicationsSection() {
<MedicationOrderEditor medications={ordered} />
)}
</SettingsCard>
{/* v1.18.0 — injection-site exclusions are a medication setting;
they moved here from the account profile so all medication-specific
preferences live in one place. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -0,0 +1,40 @@
import type { ModuleKey } from "@/lib/modules/registry";
import { surfaceModule } from "@/lib/modules/surface";
/**
* Server-safe ids and copy keys of the module pages under Settings > Modules
* (`/settings/modules/<id>`). The route reads them in
* `generateStaticParams()`, so they live apart from the client bodies in
* `module-settings-pages.tsx`.
*/
export const MODULE_SETTINGS_PAGE_IDS = [
"medications",
"mood",
"labs",
] as const;
export type ModuleSettingsPageId = (typeof MODULE_SETTINGS_PAGE_IDS)[number];
export function isModuleSettingsPageId(
value: string,
): value is ModuleSettingsPageId {
return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(value);
}
/** i18n keys of a page's heading, under `settings.sections.modules.pages`. */
export function moduleSettingsPageKeys(id: ModuleSettingsPageId): {
titleKey: string;
descriptionKey: string;
} {
return {
titleKey: `settings.sections.modules.pages.${id}.title`,
descriptionKey: `settings.sections.modules.pages.${id}.description`,
};
}
/** The module a page belongs to, from the surface map. */
export function moduleSettingsPageModule(
id: ModuleSettingsPageId,
): ModuleKey | undefined {
return surfaceModule(`settings-module:${id}`);
}
@@ -0,0 +1,117 @@
"use client";
/**
* The pages a module's own settings live on: `/settings/modules/<id>`.
*
* Settings > Arrangement holds order and view only. What a module needs
* beyond that (the biomarker catalog and on-device text recognition for
* labs, the mood tags, the injection sites a medication rotation leaves out)
* lives on the module's page under Modules, reached from the module's row in
* Settings > Modules and from the wrench on the module's own page. Each page
* links to the module's arrangement page and back.
*
* Every page is gated on its module through the surface map
* (`settings-module:<id>`), fail-open like the rest of Settings.
*/
import Link from "next/link";
import { ArrowUpDown, ChevronRight, SlidersHorizontal } from "lucide-react";
import { LabsModuleSettings } from "@/components/labs/labs-settings";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { MoodSection } from "@/components/settings/mood-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleSettingsPageId } from "./module-settings-page-ids";
/** A card pointing at the module's order-and-view page under Arrangement. */
function ArrangementLinkCard({ id }: { id: ModuleSettingsPageId }) {
const { t } = useTranslations();
return (
<SettingsCard data-slot="module-settings-arrangement-link">
<SettingsCardHeader
icon={ArrowUpDown}
title={t("settings.sections.modules.pages.arrangementTitle")}
description={t(
"settings.sections.modules.pages.arrangementDescription",
)}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href={`/settings/layout/${id}`}>
{t("settings.sections.modules.pages.openArrangement")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
function MedicationsModulePage() {
const { isAuthenticated } = useAuth();
return (
<div className="space-y-6">
<InjectionSitesCard isAuthenticated={isAuthenticated} />
<ArrangementLinkCard id="medications" />
</div>
);
}
function LabsModulePage() {
return (
<div className="space-y-6">
<LabsModuleSettings />
<ArrangementLinkCard id="labs" />
</div>
);
}
function MoodModulePage() {
return <MoodSection />;
}
export const MODULE_SETTINGS_PAGE_BODIES: Record<
ModuleSettingsPageId,
() => React.JSX.Element
> = {
medications: MedicationsModulePage,
mood: MoodModulePage,
labs: LabsModulePage,
};
export function ModuleSettingsPageBody({ id }: { id: ModuleSettingsPageId }) {
const Body = MODULE_SETTINGS_PAGE_BODIES[id];
return <Body />;
}
/**
* The way from a module's arrangement page to its own page under Modules,
* for the modules that have one.
*/
export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) {
const { t } = useTranslations();
return (
<SettingsCard data-slot="arrangement-module-settings-link">
<SettingsCardHeader
icon={SlidersHorizontal}
title={t("settings.sections.modules.pages.moduleSettingsTitle")}
description={t(
"settings.sections.modules.pages.moduleSettingsDescription",
)}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href={`/settings/modules/${id}`}>
{t("settings.sections.modules.openModuleSettings")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -0,0 +1,72 @@
"use client";
/**
* A module's on/off state, shown where the module is configured, with a link
* to the one switch in Settings > Modules.
*
* The Coach and the MCP connector each had a second switch on their own page
* beside the one in Modules: two controls for one state, and a person who
* flipped one never knew the other existed. The switch stays in Modules (the
* row carries `id="module-<key>"`); the module's page says what the state is
* and where to change it.
*/
import Link from "next/link";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
export function ModuleStateCard({
moduleKey,
enabled,
icon,
title,
description,
anchor,
slot,
children,
}: {
moduleKey: ModuleKey;
enabled: boolean;
icon: LucideIcon;
title: string;
description: string;
anchor: string;
slot: string;
/** Body copy between the header and the link, e.g. what "off" keeps. */
children?: React.ReactNode;
}) {
const { t } = useTranslations();
return (
<SettingsCard data-slot={slot} data-enabled={enabled ? "true" : "false"}>
<SettingsCardHeader
anchor={anchor}
icon={icon}
title={title}
description={description}
status={
<Badge variant={enabled ? "secondary" : "outline"}>
{enabled
? t("settings.moduleState.on")
: t("settings.moduleState.off")}
</Badge>
}
/>
{children}
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href={`/settings/modules#module-${moduleKey}`}>
{t("settings.moduleState.change")}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -92,7 +92,12 @@ export function ModuleToggleRow({
// is in flight.
const interactive = !locked && disabledReason == null;
return (
<div className="flex items-start justify-between gap-4 py-3">
// `id="module-<key>"` is the anchor other pages link to when they show a
// module's state and send the person here to change it.
<div
id={`module-${moduleKey}`}
className="flex scroll-mt-28 items-start justify-between gap-4 py-3"
>
<div className="flex min-w-0 items-start gap-3">
<Icon
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
+11 -1
View File
@@ -40,6 +40,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { MODULE_ICONS } from "@/components/settings/module-icons";
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids";
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
import { useAuth } from "@/hooks/use-auth";
import { useAiCapability } from "@/hooks/use-ai-capability";
@@ -117,6 +118,10 @@ export function ModulesSection() {
// Delegated modules carry more than on/off at their canonical
// surface (Coach cadence/memory; cycle goal/predictions/lengths),
// so keep a small deep-link to it beside the live switch.
//
// v1.44 — a module with its own page under Modules (labs, mood,
// medications) links to it the same way: what the module needs
// beyond order and view lives there, not under Arrangement.
const manageLink =
delegate !== undefined && def.managedAt
? {
@@ -125,7 +130,12 @@ export function ModulesSection() {
section: t(def.managedAt.labelKey),
}),
}
: undefined;
: isModuleSettingsPageId(key)
? {
href: `/settings/modules/${key}`,
label: t("settings.sections.modules.openModuleSettings"),
}
: undefined;
return (
<ModuleToggleRow
@@ -1,44 +0,0 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { apiFetchRaw } from "@/lib/api/api-fetch";
interface EnabledSettings {
enabled: boolean;
}
export async function persistChannelEnabled(
endpoint: string,
enabled: boolean,
fallbackError: string,
): Promise<void> {
const response = await apiFetchRaw(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
const json = (await response.json()) as { error?: string };
throw new Error(json.error || fallbackError);
}
}
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
enabled: boolean,
): T | undefined {
const previous = queryClient.getQueryData<T>(queryKey);
queryClient.setQueryData<T>(queryKey, (current) =>
current ? { ...current, enabled } : current,
);
return previous;
}
export function rollbackChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
previous: T | undefined,
): void {
queryClient.setQueryData(queryKey, previous);
}
+116 -46
View File
@@ -1,78 +1,148 @@
"use client";
import Link from "next/link";
import { Bell, Smartphone } from "lucide-react";
import { Bell, BellRing, ChevronRight, Smartphone } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationContentCard } from "@/components/settings/notification-content-card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { VorsorgeReminderSwitchesCard } from "@/components/measurement-reminders/vorsorge-settings";
import { Button } from "@/components/ui/button";
import { InlineAction } from "@/components/ui/inline-action";
/**
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
*
* v1.18.0 — the single module-gated home for reminder TYPES. Earlier the
* settings carried two near-identical entries: this Notifications screen and a
* separate "Erinnerungen" hub that only deep-linked into the canonical editors
* ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the
* reminder TYPES now live centrally on this one screen.
* The one home for every reminder kind. A reminder whose settings belong to
* one place is set here; a reminder that is set where its subject lives (a
* medication's dose times on the medication, the cycle reminders in the
* per-event matrix) gets a row here that says where, so nothing has to be
* found by luck.
*
* Each reminder-type card is shown ONLY when its module is enabled, read from
* the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and
* Insights pills gate off). Mood → `mood`, low-stock runway → `medications`
* (a toggleable fail-open module since D3, so a missing key reads as enabled
* and a stale `/me` payload never blanks a card).
* Each card is shown only while its module is on, read from the resolved
* `useAuth().user.modules` map and failing open, so a stale `/me` payload
* never blanks a card.
*
* v1.25.7 — delivery channels move to Settings → Integrationen (channels are
* delivery providers, the same family as the connected services). This screen
* keeps ONLY the reminder-TYPE content; `/settings/channels` 301-redirects to
* `/settings/integrations#channels`.
*
* v1.18.6 — the page heading + subtitle come from the shared
* `<SettingsSectionFrame>`. The proactive-Coach nudge card lives under
* Settings → Coach (it is a Coach setting, not a generic notification).
* v1.18.0 — the separate "Erinnerungen" hub is gone; `/settings/reminders`
* redirects here. v1.25.7 — the delivery channels live under Integrations
* (they are delivery providers); `/settings/channels` redirects there and this
* page links to them. v1.44 — the Coach nudges came back from Settings > Coach
* and the checkup reminder switches from the checkups arrangement page; both
* old places link here (`/settings/coach#coach-nudge` forwards).
*/
export function NotificationsSection() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
// The Coach gate reads the surface map like the Coach section does, after
// hydration, so the server render and the first client paint agree.
const mounted = useMounted();
// v1.18.0 — module-gated per-type visibility. Fail OPEN (`!== false`)
// so a stale `/me` payload without the module map keeps every card visible.
const moodEnabled = user?.modules?.mood !== false;
const medsEnabled = user?.modules?.medications !== false;
const anyReminder = moodEnabled || medsEnabled;
const cycleEnabled = user?.modules?.cycle !== false;
const coachEnabled =
!mounted || isSurfaceVisible("settings:coach", user?.modules);
return (
<div className="space-y-10">
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
least one reminder-type card is visible, so a fully-disabled
account never shows an empty heading. */}
{anyReminder ? (
<section className="space-y-4">
<SectionHeading
icon={Bell}
title={t("settings.sections.notifications.remindersHeading")}
/>
{/* Group 1 — Reminders ("what you receive"). The checkup reminders are
not a module and always show, so the group always has content. */}
<section className="space-y-4">
<SectionHeading
icon={Bell}
title={t("settings.sections.notifications.remindersHeading")}
/>
{/* Mood check-in reminder — only when the mood module is enabled. */}
{moodEnabled ? (
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{moodEnabled ? (
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{/* Medication low-stock runway — only when the medications module
is enabled (toggleable, fail-open, since D3). */}
{medsEnabled ? (
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
) : null}
</section>
) : null}
{medsEnabled ? (
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{coachEnabled ? (
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
) : null}
<div id="vorsorge-reminders" className="scroll-mt-28">
<VorsorgeReminderSwitchesCard />
</div>
{/* Reminders set where their subject lives. One row each, saying
where; nothing here writes. */}
{medsEnabled || cycleEnabled ? (
<SettingsCard data-slot="notifications-elsewhere">
<SettingsCardHeader
anchor="other-reminders"
icon={BellRing}
title={t("settings.sections.notifications.elsewhere.title")}
description={t(
"settings.sections.notifications.elsewhere.description",
)}
/>
<ul className="divide-border divide-y">
{medsEnabled ? (
<li className="space-y-1 py-3 first:pt-0">
<p className="text-sm font-medium">
{t("settings.sections.notifications.elsewhere.doses.title")}
</p>
<p className="text-muted-foreground text-xs">
{t(
"settings.sections.notifications.elsewhere.doses.description",
)}
</p>
<InlineAction asChild tone="muted">
<Link href="/medications">
{t(
"settings.sections.notifications.elsewhere.doses.link",
)}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
</li>
) : null}
{cycleEnabled ? (
<li
id="cycle-reminders"
className="scroll-mt-28 space-y-1 py-3 first:pt-0"
>
<p className="text-sm font-medium">
{t("settings.sections.notifications.elsewhere.cycle.title")}
</p>
<p className="text-muted-foreground text-xs">
{t(
"settings.sections.notifications.elsewhere.cycle.description",
)}
</p>
<InlineAction asChild tone="muted">
<Link href="/notifications">
{t(
"settings.sections.notifications.elsewhere.cycle.link",
)}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
</li>
) : null}
</ul>
</SettingsCard>
) : null}
</section>
{/* Group 2 — How notifications look on the phone. Not module-gated:
it applies to every notification the account receives. */}
+24 -60
View File
@@ -14,11 +14,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -37,10 +34,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [serverUrl, setServerUrl] = useState("https://ntfy.sh");
const [topic, setTopic] = useState("");
const [authToken, setAuthToken] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsNtfy(),
@@ -52,18 +48,22 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.serverUrl}|${settings.topic}`
? `${settings.serverUrl}|${settings.topic}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setServerUrl(settings!.serverUrl);
setTopic(settings!.topic);
setEnabled(settings!.enabled);
}
const dirty = isNtfyFormDirty({ serverUrl, topic, authToken }, settings);
const dirty = isNtfyFormDirty(
{ serverUrl, topic, authToken, enabled },
settings,
);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/ntfy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -93,46 +93,13 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
setAuthToken("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
NtfySettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsNtfy();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<NtfySettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -149,16 +116,16 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
id="ntfy-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -199,12 +166,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</div>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -216,7 +180,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
+116
View File
@@ -0,0 +1,116 @@
"use client";
/**
* Settings > System > Preview features.
*
* One switch per preview feature, each off until the person turns it on, each
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
* preview adds something beside what is there and never replaces a page.
*
* The `topics` flag is stored and shown here; the topic overview pages that
* read it arrive in a later release, and the card says so rather than
* promising a change the switch does not make yet.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { LayoutDashboard } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } 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 {
DEFAULT_PREVIEW_FEATURES,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions";
export function PreviewSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const key = queryKeys.previewFeatures();
const query = useQuery({
queryKey: key,
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
});
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
const toggle = useMutation({
mutationKey: key,
mutationFn: (topics: boolean) =>
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
onMutate: async (topics) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<PreviewFeatures>(key);
queryClient.setQueryData<PreviewFeatures>(key, {
...(previous ?? DEFAULT_PREVIEW_FEATURES),
topics,
});
return { previous };
},
onSuccess: (saved, topics) => {
if (saved) queryClient.setQueryData(key, saved);
toastWrittenOutcome(
"success",
topics
? t("settings.preview.topics.savedOn")
: t("settings.preview.topics.savedOff"),
);
},
onError: (failure, _topics, context) => {
queryClient.setQueryData(key, context?.previous);
showErrorToast(t("settings.savingError"), { cause: failure });
},
});
return (
<SettingsCard data-slot="settings-preview-topics">
<SettingsCardHeader
anchor="preview-topics"
icon={LayoutDashboard}
title={t("settings.preview.topics.title")}
titleId="settings-preview-topics-title"
description={t("settings.preview.topics.description")}
status={
<Switch
data-slot="settings-preview-topics-switch"
checked={features.topics}
disabled={query.isLoading || toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-labelledby="settings-preview-topics-title"
/>
}
/>
{query.isError ? (
<QueryErrorRow
inset
error={query.error}
slot="settings-preview-error"
message={t("settings.preview.loadError")}
onRetry={() => void query.refetch()}
/>
) : null}
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
<p className="text-muted-foreground text-xs">
<InlineAction asChild tone="muted" size="xs">
<Link
href={DISCUSSIONS_URL}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.preview.feedbackLink")}
</Link>
</InlineAction>
</p>
</SettingsCard>
);
}
+9 -37
View File
@@ -4,18 +4,20 @@
* v1.23 — "Data & Privacy" dashboard (P7).
*
* One coherent pane that ASSEMBLES already-shipped machinery — it does not
* rebuild any of it. Each block either embeds an existing card (active
* sessions, security activity, trusted devices) or links to the existing
* surface that owns the action (export incl. the passphrase option, account
* deletion / data reset, AI privacy mode, research mode). The retention +
* encryption facts are read from the server so the copy stays truthful, and
* the backup↔deletion lag is disclosed honestly.
* rebuild any of it. Each block states a fact or links to the surface that
* owns the action (account deletion / data reset, AI privacy mode). The
* retention + encryption facts are read from the server so the copy stays
* truthful, and the backup↔deletion lag is disclosed honestly.
*
* v1.44 — the active sessions, trusted devices and security activity moved
* to Settings > Security beside the sign-in controls, and the export card
* left: Export & import and the Health record sit next to this page in the
* Data group. The old anchors forward (`settings-anchor-forward.tsx`).
*/
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import {
Database,
Download,
ShieldCheck,
Trash2,
Clock,
@@ -29,9 +31,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { WorkoutRoutesCard } from "@/components/settings/workout-routes-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -181,28 +180,6 @@ export function PrivacySection() {
)}
</SettingsCard>
{/* Export */}
<SettingsCard>
<SettingsCardHeader
anchor="your-data-export"
icon={Download}
title={t("settings.privacy.export.title")}
description={t("settings.privacy.export.description")}
/>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/export">
{t("settings.privacy.export.openExport")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/gesundheitsakte">
{t("settings.privacy.export.openHealthRecord")}
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
{/* Delete / reset — with the honest backup lag disclosure */}
<SettingsCard>
<SettingsCardHeader
@@ -241,11 +218,6 @@ export function PrivacySection() {
</Button>
</SettingsCardActions>
</SettingsCard>
{/* Active sessions + trusted devices + security activity (embedded) */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
</div>
);
}
+3
View File
@@ -114,6 +114,9 @@ export const SETTINGS_SECTION_SLUGS = [
// pieces into one coherent privacy pane. Sits next to `advanced` (the
// destructive-action home) and before `about`.
"privacy",
// v1.44 — "Preview features": per-user opt-in to things finished enough to
// try and not yet the default. Sits in the System group before About.
"preview",
"about",
] as const;
@@ -7,6 +7,9 @@ import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { PasswordCard } from "@/components/settings/password-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
@@ -79,6 +82,15 @@ export function SecuritySection() {
`/api/auth/me/mfa` that never answers must not take it down with the
cards that do need the status. */}
<PasswordCard />
{/* v1.44 — who is signed in and what happened, beside the controls
that decide how one signs in. These lived on the privacy page;
`/settings/privacy#sessions` (and the other two anchors) forward
here. Outside the status conditional for the same reason as the
password: they read their own routes. */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -27,6 +27,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -311,46 +312,21 @@ export function PasskeyListSection({
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(pk.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.deletePasskey")}
title={t("settings.deletePasskey")}
body={t("settings.deletePasskeyDescription")}
confirmLabel={t("common.delete")}
onConfirm={() => remove.mutate(pk.id)}
pending={remove.isPending && remove.variables === pk.id}
disabled={remove.isPending}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
icon={<Trash2 className="h-4 w-4" />}
destructive
/>
</div>
)}
</div>
@@ -13,18 +13,8 @@ import {
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -308,46 +298,23 @@ export function SecurityKeysCard({
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.security.keys.remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.keys.remove")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.keys.removeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(key.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.security.keys.remove")}
title={t("settings.security.keys.remove")}
body={t("settings.security.keys.removeConfirm")}
confirmLabel={t("common.delete")}
onConfirm={() => remove.mutate(key.id)}
pending={
remove.isPending && remove.variables === key.id
}
disabled={remove.isPending}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
icon={<Trash2 className="h-4 w-4" />}
destructive
/>
</div>
)}
</div>
@@ -498,6 +498,7 @@ export function TotpCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
data-testid="recovery-regenerate-confirm"
disabled={regenerate.isPending}
aria-busy={regenerate.isPending || undefined}
@@ -0,0 +1,70 @@
"use client";
/**
* Old deep links into cards that moved to another Settings page.
*
* A path that moved is a server redirect (`next.config.ts`), but a card that
* moved while its page stayed cannot be: the `#anchor` never reaches the
* server. Bookmarks, cross-links in older notifications and the docs point at
* `/settings/privacy#sessions` and the like, so the shell forwards a known
* anchor on a known page to the card's new home, once, on arrival.
*
* `SETTINGS_ANCHOR_FORWARDS` is also read by the legacy-routes guard, which
* proves every target lands on a page that exists.
*/
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
export const SETTINGS_ANCHOR_FORWARDS: Readonly<
Record<string, Readonly<Record<string, string>>>
> = {
// v1.44 — sessions, trusted devices and the security activity joined the
// other sign-in controls under Security; the duplicate export card left.
"/settings/privacy": {
sessions: "/settings/security#sessions",
"trusted-devices": "/settings/security#trusted-devices",
"security-activity": "/settings/security#security-activity",
"your-data-export": "/settings/export",
},
// v1.44 — the proactive nudges are a reminder and live with the others.
"/settings/coach": {
"coach-nudge": "/settings/notifications#coach-nudge",
},
// v1.44 — Arrangement holds order and view only; each module's own
// settings moved to its page under Modules.
"/settings/layout/labs": {
"labs-local-ocr": "/settings/modules/labs#labs-local-ocr",
"labs-biomarkers": "/settings/modules/labs#labs-biomarkers",
},
"/settings/layout/medications": {
"injection-sites": "/settings/modules/medications#injection-sites",
},
"/settings/layout/vorsorge": {
"screening-hints": "/settings/anamnesis#screening-hints",
"vorsorge-reminders": "/settings/notifications#vorsorge-reminders",
},
};
/** Where an anchor on a page now lives, or null when it did not move. */
export function forwardedAnchorTarget(
pathname: string,
hash: string,
): string | null {
const anchor = hash.startsWith("#") ? hash.slice(1) : hash;
if (!anchor) return null;
return SETTINGS_ANCHOR_FORWARDS[pathname]?.[anchor] ?? null;
}
export function SettingsAnchorForward() {
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (!pathname) return;
const target = forwardedAnchorTarget(pathname, window.location.hash);
if (target) router.replace(target);
}, [pathname, router]);
return null;
}
@@ -0,0 +1,131 @@
"use client";
/**
* Settings navigation below `md`: one button naming where the person is, and a
* bottom sheet listing every section under its group.
*
* The phone used to carry every section as a chip in one horizontal strip,
* more than twenty of them, so most of Settings sat off screen and the groups
* the desktop sidebar shows were reduced to thin dividers. The sheet shows the
* same groups and order as the sidebar, one 44 px row per section, and closes
* on choice. Both read the visible-section list the shell computes, so the
* module and record filters apply to both alike.
*/
import * as React from "react";
import Link from "next/link";
import { ChevronsUpDown } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
export interface SettingsNavEntry {
slug: string;
title: string;
groupLabel: string;
icon: LucideIcon;
}
export function SettingsMobileNav({
entries,
activeSlug,
}: {
entries: readonly SettingsNavEntry[];
activeSlug: string;
}) {
const { t } = useTranslations();
const [open, setOpen] = React.useState(false);
const active = entries.find((entry) => entry.slug === activeSlug);
const ActiveIcon = active?.icon;
// Group the flat, pre-ordered list for rendering; the order of first
// appearance is the group order.
const groups: { label: string; entries: SettingsNavEntry[] }[] = [];
for (const entry of entries) {
const last = groups.at(-1);
if (last && last.label === entry.groupLabel) last.entries.push(entry);
else groups.push({ label: entry.groupLabel, entries: [entry] });
}
return (
<nav
aria-label={t("settings.shell.sectionsNav")}
className="mb-4 md:hidden"
>
<button
type="button"
data-slot="settings-mobile-nav-trigger"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
className="border-border bg-card hover:bg-accent focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
{ActiveIcon ? (
<ActiveIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
) : null}
<span className="min-w-0 flex-1">
{active ? (
<span className="text-muted-foreground block text-xs">
{active.groupLabel}
</span>
) : null}
<span className="block truncate text-sm font-medium">
{active?.title ?? t("settings.shell.allSections")}
</span>
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("settings.shell.allSections")}
</span>
<ChevronsUpDown
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</button>
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={t("settings.shell.allSections")}
>
<div className="space-y-4 pb-2" data-slot="settings-mobile-nav-list">
{groups.map((group) => (
<section key={group.label} className="space-y-1">
<h2 className="text-muted-foreground px-1 text-xs font-medium tracking-wide uppercase">
{group.label}
</h2>
<ul className="space-y-0.5">
{group.entries.map((entry) => {
const isActive = entry.slug === activeSlug;
const Icon = entry.icon;
return (
<li key={entry.slug}>
<Link
href={`/settings/${entry.slug}`}
aria-current={isActive ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
"flex min-h-11 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
{entry.title}
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
</ResponsiveSheet>
</nav>
);
}
@@ -11,8 +11,8 @@ import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
/**
* One settings section, loaded on its own.
*
* The section route renders exactly one of twenty-one sections, but importing
* them statically put all twenty-one (and everything under them: the import
* The section route renders exactly one of the sections, but importing
* them statically put all of them (and everything under them: the import
* panel, the integration cards, the MCP and API token tables, the score
* editor) into the route's entry bundle, so opening "About" downloaded the
* whole settings tree. Each section is now its own chunk. A first load still
@@ -124,6 +124,10 @@ const LOADERS: Record<
import("@/components/settings/privacy-section").then(
(m) => m.PrivacySection,
),
preview: () =>
import("@/components/settings/preview-section").then(
(m) => m.PreviewSection,
),
};
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
@@ -158,6 +162,7 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
};
/**
+112 -219
View File
@@ -9,9 +9,10 @@
* by the page itself.
*
* Mobile-first per `docs/ui-guidelines.md`:
* - <md: section selector renders as a horizontal scroll strip at the top so
* a thumb-tap can swap sections without leaving the viewport.
* - >=md: sticky 220px sidebar + content column with `max-w-screen-xl`.
* - <md: one button naming the current section opens a bottom sheet with
* every section under its group (`SettingsMobileNav`).
* - >=md: sticky 220px sidebar, grouped the same way, beside a content
* column with `max-w-screen-xl`.
*/
import * as React from "react";
@@ -25,6 +26,7 @@ import {
CloudSun,
Download,
FileHeart,
FlaskConical,
Gauge,
Info,
KeyRound,
@@ -56,6 +58,8 @@ import {
type SettingsSectionSlug,
} from "./section-slugs";
import { SettingsHubBackLink } from "./settings-hub-back-link";
import { SettingsMobileNav } from "./settings-mobile-nav";
import { SettingsAnchorForward } from "./settings-anchor-forward";
// Re-export so existing call-sites importing from this module keep working.
// New server-side call-sites (e.g. `generateStaticParams()`) should import
@@ -71,18 +75,24 @@ export {
};
/**
* The sidebar's 19 entries render under one
* of these seven non-interactive group headers instead of as one flat
* list. Presentation only: no slug, route, or redirect changes. Order
* below is the render order of the groups themselves.
* The nine groups every section belongs to, in render order. The sidebar
* paints a non-interactive header above the first visible entry of each, and
* the phone's section sheet uses the same headers.
*
* Account: who you are and who can get in. Tracking: what the app records.
* Arrangement: order and view only. Notifications: every reminder. Health
* profile: what describes you. AI and Connections: what talks to the outside.
* Data: export, import, privacy and deletion. System: previews and about.
*/
export type SettingsSectionGroup =
| "account"
| "yourData"
| "display"
| "tracking"
| "arrangement"
| "notifications"
| "healthProfile"
| "ai"
| "connectivity"
| "connections"
| "data"
| "system";
/** i18n key for each group's header label, under `settings.shell.groups.*`. */
@@ -91,11 +101,13 @@ export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record<
string
> = {
account: "settings.shell.groups.account",
yourData: "settings.shell.groups.yourData",
display: "settings.shell.groups.display",
tracking: "settings.shell.groups.tracking",
arrangement: "settings.shell.groups.arrangement",
notifications: "settings.shell.groups.notifications",
healthProfile: "settings.shell.groups.healthProfile",
ai: "settings.shell.groups.ai",
connectivity: "settings.shell.groups.connectivity",
connections: "settings.shell.groups.connections",
data: "settings.shell.groups.data",
system: "settings.shell.groups.system",
};
@@ -109,159 +121,84 @@ interface SettingsSection {
}
/**
* Source of truth for the section list that the settings shell renders
* in its sidebar + mobile chip-strip.
* Source of truth for the section list that the settings shell renders in
* its sidebar and in the phone's section sheet, pre-ordered by group.
*
* The list used to render in a single flat
* order with no visual structure (19 entries deep, the AI cluster and the
* programmatic-access cluster interleaved, the health-profile pair split
* across the list). It is now clustered into the seven
* `SettingsSectionGroup`s, each entry carrying its `group`; the list is
* pre-ordered by group and the shell inserts a non-interactive header
* before the first VISIBLE entry of each group. This is a presentation-only
* reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS`
* (`section-slugs.ts`, which also carries the page-only slugs and drives
* `generateStaticParams()`) is untouched.
* `SETTINGS_SECTION_SLUGS` (`section-slugs.ts`) is the route registry and
* keeps its own order (it drives `generateStaticParams()` and the record
* landing order); this list decides only where each entry is shown. Every
* slug a person ever reached keeps its URL: a section that moved between
* groups moved here, not in the route.
*
* "About" sits at the end of the list (System group). v1.4.33 IW7 had
* folded it into the sidebar user-card dropdown only, which left
* `/settings/about` an orphaned route — reachable by URL but discoverable
* nowhere in the settings navigation. It is now both a regular shell entry
* and a user-card dropdown item ("Über HealthLog" / "About HealthLog").
* The card titles people cite stay as they are: "Export & import",
* "Integrations", "API tokens", "Shared access", "Modules".
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
// ── Account ──
// ── Account ── profile and display, sign-in and sessions, who else may open
// this record.
{
slug: "account",
titleKey: "settings.sections.account.title",
icon: User,
group: "account",
},
// v1.23 — the account-security home: second factors, recovery codes,
// passkey management.
// Second factors, passkeys, password, and (since v1.44) the active
// sessions, trusted devices and security activity that used to sit on the
// privacy page.
{
slug: "security",
titleKey: "settings.sections.security.title",
icon: ShieldCheck,
group: "account",
},
// v1.36.0 — who else may open this record, and whose this account may open.
// In the Account group beside Security: both answer "who can get at my
// account and what have they done", and a person looking for one will look
// where the other is.
{
slug: "access",
titleKey: "settings.sections.access.title",
icon: UsersRound,
group: "account",
},
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one coherent privacy pane.
{
slug: "privacy",
titleKey: "settings.sections.privacy.title",
icon: Lock,
group: "account",
},
// ── Your data ──
// v1.18.0 — the "Was du trackst" hub: the front door for enabling /
// disabling the secondary tracking domains.
// ── Tracking ── which areas are recorded; each module's own settings
// (biomarkers, mood tags, injection sites) open from its row.
{
slug: "modules",
titleKey: "settings.sections.modules.title",
icon: Blocks,
group: "yourData",
group: "tracking",
},
{
slug: "integrations",
titleKey: "settings.sections.integrations.title",
icon: Link2,
group: "yourData",
},
// v1.18.1 — Quellen (source weighting): which connection wins when
// two report the same metric. v1.25.3 — Kanäle (delivery channels) folded
// back into Notifications as an in-page group;
// `/settings/channels` 301-redirects to `/settings/notifications#channels`.
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
group: "yourData",
},
// v1.18.0 — the full health record (PDF + FHIR R4 + zip package).
// v1.18.6.1 — NOT module-gated: the health record is a flagship export
// capability; gating its only entry-point on the `doctorReport` opt-out
// meant a stray toggle in the Modules hub made the whole section vanish
// with no in-context way back. The `doctorReport` module still governs
// the data layer server-side (403 `module.disabled` over Bearer).
{
slug: "gesundheitsakte",
titleKey: "settings.sections.gesundheitsakte.title",
icon: FileHeart,
group: "yourData",
},
// v1.25.7 — clinician share links fold into the Gesundheitsakte section as
// a labelled "Sharing" group; `/settings/sharing` 301-redirects there.
{
slug: "export",
titleKey: "settings.sections.export.title",
icon: Download,
group: "yourData",
},
// ── Display ──
// v1.17.1 — one nav entry replaces the four scattered Dashboard /
// Insights / Medications / Mood "arrange" entries; those routes still
// resolve, reached through this hub. v1.25.3 — renamed to "Appearance" /
// "Darstellung" and widened into the index for every module's
// view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) —
// each module lives on its own subpage at `/settings/layout/<module>`;
// the old per-module routes 301-redirect there.
// ── Arrangement ── order and view, nothing else. The slug stays `layout`.
{
slug: "layout",
titleKey: "settings.sections.layout.title",
icon: Palette,
group: "display",
group: "arrangement",
},
// v1.25.7 — Vorsorge (preventive-care reminders) folded into
// "Darstellung"; `/settings/vorsorge` 301-redirects to
// `/settings/layout/vorsorge`.
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
group: "display",
},
// v1.18.0 — "Benachrichtigungen" is the single module-gated
// reminder-types home; the standalone "Erinnerungen" hub is gone.
// ── Notifications ── every reminder kind, or the link to where it lives.
{
slug: "notifications",
titleKey: "settings.sections.notifications.title",
icon: Bell,
group: "display",
group: "notifications",
},
// ── Health profile ──
// v1.35.0 — which pillars count toward the Health Score. Sits at the top
// of the health-profile cluster: it is a statement about the person's own
// health picture, not about how the app looks or what it records.
{
slug: "score",
titleKey: "settings.sections.score.title",
icon: Gauge,
group: "healthProfile",
},
// v1.25 — Anamnese (medical history): the structured-record
// home for allergies + family history. NOT module-gated (like Vorsorge) —
// these are foundational health-profile records, always available.
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
group: "healthProfile",
},
{
slug: "anamnesis",
titleKey: "settings.sections.anamnesis.title",
icon: ClipboardList,
group: "healthProfile",
},
// v1.25 — Environmental context: home location, travel overrides,
// and the weather/daylight backfill. Module-gated on the opt-in
// `environment` module (`settings:environment` in the surface map), so the
// entry only appears once the user turns it on.
// Module-gated on the opt-in `environment` module.
{
slug: "environment",
titleKey: "settings.sections.environment.title",
@@ -275,37 +212,73 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
icon: Sparkles,
group: "ai",
},
// v1.18.0 — Coach: the Coach preference cards get their own entry,
// shown only when the coach module is enabled (`settings:coach` in the
// surface map). The AI entry above keeps
// provider / model / BYOK configuration.
// Module-gated on the coach module (`settings:coach`).
{
slug: "coach",
titleKey: "settings.sections.coach.title",
icon: Bot,
group: "ai",
},
// ── Connectivity ──
// ── Connections ── data coming in (integrations, which source wins) and
// programmatic access going out (tokens, the MCP connector).
{
slug: "integrations",
titleKey: "settings.sections.integrations.title",
icon: Link2,
group: "connections",
},
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
group: "connections",
},
{
slug: "api",
titleKey: "settings.sections.api.title",
icon: KeyRound,
group: "connectivity",
group: "connections",
},
// v1.22.0 — remote MCP connector. Not module-gated: the card carries its
// own enable toggle (mirroring `gesundheitsakte`), so the entry-point
// stays reachable even with the opt-in module off.
// Not module-gated: the page shows the connector's state and links to the
// one switch in Modules.
{
slug: "mcp",
titleKey: "settings.sections.mcp.title",
icon: Plug,
group: "connectivity",
group: "connections",
},
// ── Data ── getting data out and in, what is stored, and deleting it.
{
slug: "export",
titleKey: "settings.sections.export.title",
icon: Download,
group: "data",
},
// Not module-gated: the health record is a flagship export, and its only
// entry point must not vanish behind the `doctorReport` opt-out.
{
slug: "gesundheitsakte",
titleKey: "settings.sections.gesundheitsakte.title",
icon: FileHeart,
group: "data",
},
{
slug: "privacy",
titleKey: "settings.sections.privacy.title",
icon: Lock,
group: "data",
},
// ── System ──
{
slug: "advanced",
titleKey: "settings.sections.advanced.title",
icon: Settings2,
group: "data",
},
// ── System ──
{
slug: "preview",
titleKey: "settings.sections.preview.title",
icon: FlaskConical,
group: "system",
},
{
@@ -453,40 +426,6 @@ export function SettingsShell({
(section) => section.slug === activeSlug,
);
// v1.4.33 — keep the active chip in view inside the horizontal
// mobile strip. On a 393 CSS px viewport the strip is wider than the
// viewport and the rightmost chips sit off-screen; a user who tapped
// one of those chips lands on the new route with the strip still
// scrolled to the leftmost chip, which reads as if the navigation is
// broken. We pin the active chip to the left edge of the strip.
//
// v1.7.0 — adjust only the strip's own horizontal scroll offset
// (via `scrollTo({ left })`) instead of `Element.scrollIntoView()`.
// `scrollIntoView` walks every scrollable ancestor and adjusts both
// axes, so on mobile it also nudged the whole document vertically on
// each route change — a dizzy auto-scroll that fired on every settings
// sub-page tap. Scrolling the strip's own `scrollLeft` confines the
// motion to its horizontal axis and never touches the page scroll
// position.
const mobileStripRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (typeof window === "undefined") return;
const strip = mobileStripRef.current;
if (!strip) return;
const active = strip.querySelector<HTMLElement>('[aria-current="page"]');
if (!active) return;
// Land the active chip at the strip's left edge, clamped to the
// scrollable range. `offsetLeft` is relative to the strip's padding
// box, which is the origin for `scrollLeft`.
const maxScroll = strip.scrollWidth - strip.clientWidth;
const target = Math.max(0, Math.min(active.offsetLeft, maxScroll));
// Position the active chip instantly. A smooth behaviour here animates
// the strip from its reset `scrollLeft: 0` to the target on every tap,
// which reads as an unwanted "scroll from the start" sweep when swapping
// sections. An instant jump keeps the chip in view without the sweep.
strip.scrollTo({ left: target, behavior: "auto" });
}, [activeSlug]);
// v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading`
// (the canonical path); the slug fallback keeps the shell renderable on
// its own (and in tests that mount it without a heading) by deriving the
@@ -549,64 +488,18 @@ export function SettingsShell({
<div className="mb-4 md:hidden">{headingBlock(true)}</div>
) : null}
{/* Mobile section strip — horizontal scroll, hidden on md+.
`no-scrollbar` (defined in `globals.css`) suppresses the
painted scrollbar; the horizontal swipe + keyboard arrow
scrolling still work. Without it the 10-section strip
renders an always-on scrollbar at the top of every settings
page, which makes the page feel like every section card has
an overflow problem.
v1.4.33 — `snap-x snap-mandatory` lets a swipe-flick land
on the next chip's leading edge instead of the in-between
dead zone. The auto `scrollIntoView({inline: "center"})`
effect above stays the canonical positioner for the active
chip; snap is the polish layer when the user manually flicks
through the strip without tapping. */}
<nav
ref={mobileStripRef}
aria-label={t("settings.shell.sectionsNav")}
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max items-center gap-2">
{visibleSections.map((section, index) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
// A quiet vertical divider marks a
// group boundary in the horizontal strip (the desktop sidebar
// carries the full text header for the same grouping).
const startsNewGroup =
index > 0 && visibleSections[index - 1].group !== section.group;
return (
<React.Fragment key={section.slug}>
{startsNewGroup ? (
<li aria-hidden="true" className="shrink-0">
<span className="bg-border block h-6 w-px" />
</li>
) : null}
<li className="snap-start">
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
// v1.4.25 — chip strip is the primary mobile-settings
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
</React.Fragment>
);
})}
</ul>
</nav>
<SettingsAnchorForward />
{/* Below `md`: one button naming the current section, opening the
grouped section sheet. Hidden on md+, where the sidebar shows. */}
<SettingsMobileNav
activeSlug={activeSlug}
entries={visibleSections.map((section) => ({
slug: section.slug,
title: t(section.titleKey),
groupLabel: t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group]),
icon: section.icon,
}))}
/>
{/* v1.18.6.1 — two-row grid on desktop:
row 1 / col 2 = heading (content column only)
+16 -43
View File
@@ -26,19 +26,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -255,38 +245,21 @@ function ShareLinksCard() {
{/* §12 — a right-aligned row action, not a full-width
destructive strip stacked once per link (which reads as
an alarm wall on a list of shares). */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.sharing.revoke")}
title={t("settings.sharing.revoke")}
body={t("settings.sharing.revokeDescription")}
confirmLabel={t("settings.sharing.revoke")}
onConfirm={() => revokeMutation.mutate(link.id)}
pending={
revokeMutation.isPending &&
revokeMutation.variables === link.id
}
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
))}
</ul>
@@ -697,6 +697,7 @@ export function SourcesSection() {
disabled={saveMutation.isPending}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
destructive
/>
{dirty && priority ? (
<>
+26 -79
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Info, Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -39,9 +36,10 @@ export function TelegramCard({
const queryClient = useQueryClient();
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.telegramSettings(),
@@ -52,67 +50,25 @@ export function TelegramCard({
});
// Sync from server using the React-recommended "previous-payload-id"
// pattern instead of setState-in-effect. This avoids the strict
// `react-hooks/set-state-in-effect` lint failure that the legacy file
// disabled at module scope.
const settingsKey = settings ? (settings.chatId ?? "") : null;
// pattern instead of setState-in-effect. Reseeds after every save, so the
// switch reads what the server stored.
const settingsKey = settings
? `${settings.chatId ?? ""}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
if (settings!.chatId) setChatId(settings!.chatId);
setEnabled(settings!.enabled);
}
const dirty = isTelegramFormDirty({ botToken, chatId }, settings);
const toggleEnabled = useMutation<
void,
Error,
boolean,
TelegramSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/telegram",
enabled,
t("settings.savingError"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.telegramSettings();
await queryClient.cancelQueries({ queryKey });
setMsg(null);
setMsgType(null);
return optimisticallySetChannelEnabled<TelegramSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setMsg(t("settings.saved"));
setMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.telegramSettings(),
previous,
);
setMsg(err.message);
setMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() });
},
});
const dirty = isTelegramFormDirty({ botToken, chatId, enabled }, settings);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
setError(null);
const body: Record<string, unknown> = {
enabled: settings?.enabled ?? false,
};
const body: Record<string, unknown> = { enabled };
if (botToken.trim()) body.botToken = botToken.trim();
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
@@ -126,16 +82,16 @@ export function TelegramCard({
}).catch(() => null);
if (res?.ok) {
setMsg(t("settings.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.saved"));
setBotToken("");
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
} else {
const json = (await res?.json().catch(() => null)) as {
error?: string;
} | null;
setMsg(json?.error || t("settings.savingError"));
setMsgType("error");
const message = json?.error || t("settings.savingError");
setError(message);
showErrorToast(message);
}
} finally {
setSaving(false);
@@ -192,9 +148,9 @@ export function TelegramCard({
<div className="flex items-center gap-3">
<Switch
id="tg-enabled"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={saving || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={saving}
/>
<Label htmlFor="tg-enabled" className="cursor-pointer">
{t("settings.enableNotifications")}
@@ -209,14 +165,9 @@ export function TelegramCard({
</ol>
</SettingsInfoTile>
{msg && (msgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{msg}
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
@@ -226,11 +177,7 @@ export function TelegramCard({
disabled={!settings?.hasBotToken}
unsavedChanges={dirty}
/>
<Button
type="submit"
disabled={saving || toggleEnabled.isPending}
className="min-h-11"
>
<Button type="submit" disabled={saving} className="min-h-11">
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -179,6 +179,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
confirmLabel={t("thresholds.resetAllConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate(null)}
destructive
/>
</SettingsCardActions>
) : null}
@@ -371,6 +372,7 @@ function MetricRow({
setOverrideMode(false);
onReset();
}}
destructive
/>
{hasOverride && (
<Badge variant="outline" className="text-xs">
@@ -445,6 +447,7 @@ function MetricRow({
confirmLabel={t("thresholds.resetConfirm")}
pending={busy}
onConfirm={onReset}
destructive
/>
)}
</div>
+8 -49
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -39,30 +42,6 @@ export function TimeFormatSelect({
const [optimistic, setOptimistic] = useState<TimeFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO";
const mutation = useMutation({
@@ -84,31 +63,19 @@ export function TimeFormatSelect({
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeTimeFormat(next);
setMsg(t("settings.timeFormat.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.timeFormat.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error ? err.message : t("settings.timeFormat.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.timeFormat.saveError"), { cause: err });
},
});
function handleSelect(next: TimeFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -126,16 +93,8 @@ export function TimeFormatSelect({
<option value="H24">{t("settings.timeFormat.h24")}</option>
<option value="H12">{t("settings.timeFormat.h12")}</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.timeFormat.description")}
<p className="text-muted-foreground text-xs">
{t("settings.timeFormat.description")}
</p>
</div>
);
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { resolveInitialTimezone } from "@/components/settings/account-section/account-section-utils";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { InlineAction } from "@/components/ui/inline-action";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { apiFetchRaw } 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 { storeTimezone } from "@/lib/timezone-mirror";
import { DEFAULT_TIMEZONE, detectBrowserTimezone } from "@/lib/tz/format";
/**
* The profile timezone as a Display preference: it saves the moment it
* changes, like the language, units and formats beside it.
*
* Until v1.44 it sat in the profile form and waited for that form's Save,
* which made it the one display preference that did not take effect on
* change. Persistence is unchanged (`PUT /api/auth/me/timezone`, the route
* that owns the IANA validation). The local mirror is written on success so
* every rendered timestamp repaints in the same tab before the `/me`
* refetch confirms it (issue #490).
*
* The select shows the stored zone: a value on screen is a value that is
* saved. The form used to pre-fill the device's zone over the untouched
* default and save it with the next profile save; that suggestion stays,
* as a one-tap offer under the select (`resolveInitialTimezone` decides
* when), instead of an unsaved value dressed as the current one.
*/
export function TimezoneSelect({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<string | null>(null);
const value = optimistic ?? user?.timezone ?? DEFAULT_TIMEZONE;
const mutation = useMutation({
mutationFn: async (next: string) => {
const res = await apiFetchRaw("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone: next }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return next;
},
onSuccess: (next) => {
storeTimezone(next);
toastWrittenOutcome("success", t("settings.timezoneSaved"));
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
},
onError: (err) => {
setOptimistic(null);
showErrorToast(t("settings.timezoneInvalid"), { cause: err });
},
});
function handleChange(next: string) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
mutation.mutate(next);
}
// The device zone is read after hydration so both renders agree.
const mounted = useMounted();
const suggestion = mounted
? resolveInitialTimezone(user?.timezone, detectBrowserTimezone())
: value;
return (
<div className="space-y-1">
<TimezonePicker value={value} onChange={handleChange} />
{suggestion !== value && optimistic === null ? (
<InlineAction
tone="primary"
size="xs"
data-slot="timezone-use-device"
disabled={mutation.isPending || !isAuthenticated}
onClick={() => handleChange(suggestion)}
>
{t("settings.timezoneUseDevice", { zone: suggestion })}
</InlineAction>
) : null}
</div>
);
}
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -36,30 +39,6 @@ export function UnitPreferenceSelect({
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<UnitPreference | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric";
const mutation = useMutation({
@@ -78,36 +57,22 @@ export function UnitPreferenceSelect({
return next;
},
onSuccess: () => {
setMsg(t("settings.dashboard.units.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dashboard.units.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userUnitPreference(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.units.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dashboard.units.saveError"), { cause: err });
},
});
function handleSelect(next: UnitPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -126,16 +91,8 @@ export function UnitPreferenceSelect({
{t("settings.dashboard.units.imperial")}
</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.units.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dashboard.units.description")}
</p>
</div>
);
+4 -3
View File
@@ -9,17 +9,18 @@
* The shell supplies the page chrome (left nav, heading, subtitle) so this
* body is the preventive-care cards only. Preventive-care reminders are not
* a toggleable module, so this entry carries no `moduleGate` (always shown).
*
* v1.44 — order and view only. The reminder switches moved to Settings >
* Notifications, the screening hints to Health profile > Medical history
* beside the life circumstances they come from.
*/
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
export function VorsorgeSection() {
return (
<div className="space-y-6">
<VorsorgeSettings />
{/* v1.43 (#614) — hints from the life circumstances, with their source. */}
<ScreeningHintsCard />
</div>
);
}
+21 -68
View File
@@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -41,10 +38,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [headerName, setHeaderName] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsWebhook(),
@@ -56,7 +52,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
@@ -64,14 +60,15 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setUrl(settings!.url);
setHeaderName(settings!.headerName);
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
setEnabled(settings!.enabled);
}
const dirty = isWebhookFormDirty(
{ url, headerName, headerValue, format },
{ url, headerName, headerValue, format, enabled },
settings,
);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -102,54 +99,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
setHeaderValue("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
WebhookSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/webhook",
enabled,
t("common.error"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsWebhook();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<WebhookSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.settingsWebhook(),
previous,
);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -166,16 +122,16 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -244,12 +200,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</div>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -261,7 +214,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
@@ -241,3 +241,35 @@ describe("<ConfirmDialog> — the trigger-less half", () => {
expect(carriers.map(nameOf)).toEqual(["ConfirmBody"]);
});
});
describe("ConfirmDialog with holdOpen — the request stays on screen", () => {
const onConfirm = vi.fn();
const body = walk(
ConfirmDialog({
open: true,
onOpenChange: () => {},
onConfirm,
pending: true,
holdOpen: true,
...COPY,
}) as ReactElement,
).find((el) => nameOf(el) === "ConfirmBody")!;
const rendered = walk(
(body.type as (p: unknown) => ReactElement)(body.props),
);
it("keeps the dialog open on confirm and still calls back", () => {
const action = rendered.find((el) => nameOf(el) === "AlertDialogAction")!;
const preventDefault = vi.fn();
(action.props as { onClick: (e: unknown) => void }).onClick({
preventDefault,
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("holds Cancel while the request runs", () => {
const cancel = rendered.find((el) => nameOf(el) === "AlertDialogCancel")!;
expect((cancel.props as { disabled?: boolean }).disabled).toBe(true);
});
});
+35 -3
View File
@@ -52,6 +52,12 @@ interface ConfirmCopy {
pending?: boolean;
/** `data-slot` for tests and the design guards. */
slot?: string;
/**
* `data-testid` for the trigger; the dialog's action carries
* `<testId>-confirm`. For call sites whose tests already address the
* control by test id.
*/
testId?: string;
/** A control that belongs with the question, such as an opt-out checkbox. */
extra?: ReactNode;
/**
@@ -59,6 +65,16 @@ interface ConfirmCopy {
* data-list selection bar's confirm already does.
*/
destructive?: boolean;
/**
* The confirm keeps the dialog open: the caller closes it once the act
* settled, and Cancel waits while `pending`. For an act whose in-flight
* state must stay on screen (an irreversible delete), where a dismissal
* mid-request would read as cancelled. Only meaningful on `ConfirmDialog`,
* whose caller owns `open`.
*/
holdOpen?: boolean;
/** The confirm stays disabled, e.g. until a field in `extra` is valid. */
confirmDisabled?: boolean;
}
function ConfirmBody({
@@ -68,8 +84,11 @@ function ConfirmBody({
onConfirm,
pending = false,
slot,
testId,
extra,
destructive = false,
holdOpen = false,
confirmDisabled = false,
}: ConfirmCopy) {
const { t } = useTranslations();
@@ -81,13 +100,23 @@ function ConfirmBody({
</AlertDialogHeader>
{extra}
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogCancel disabled={holdOpen && pending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
data-slot={slot ? `${slot}-confirm` : undefined}
disabled={pending}
data-testid={testId ? `${testId}-confirm` : undefined}
disabled={pending || confirmDisabled}
aria-busy={pending || undefined}
onClick={onConfirm}
onClick={
holdOpen
? (event) => {
event.preventDefault();
onConfirm();
}
: onConfirm
}
>
{pending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
@@ -113,6 +142,7 @@ export function ConfirmButton({
icon,
ariaLabel,
slot,
testId,
extra,
destructive = false,
}: ConfirmCopy & {
@@ -136,6 +166,7 @@ export function ConfirmButton({
className={className}
aria-label={ariaLabel}
data-slot={slot}
data-testid={testId}
disabled={disabled || pending}
>
{pending ? (
@@ -156,6 +187,7 @@ export function ConfirmButton({
onConfirm={onConfirm}
pending={pending}
slot={slot}
testId={testId}
extra={extra}
destructive={destructive}
/>
+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")) {
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more