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