mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge branch 'feat/v144-settings' into release/v1.44.0
# Conflicts: # src/components/settings/privacy-section.tsx
This commit is contained in:
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))(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user