Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:17:40 +02:00
commit 4c2dfa57b4
138 files changed
+12046 -137

No files matched your search

@@ -1,8 +1,11 @@
/**
* Copy-matches-behaviour guard.
*
* Three classes of copy that drifted away from the code they describe, each
* pinned here so the pair fails loudly if either half moves again:
* Four classes of copy that must not drift away from the code they describe,
* each pinned here so the pair fails loudly if either half moves. The first
* three are repairs — copy that had already drifted. The fourth arrives with
* the feature it governs, because a wording rule that depends on review is a
* wording rule that survives until the fifth locale.
*
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
* consequence. Four dialogs promised permanence on surfaces that render
@@ -224,3 +227,150 @@ describe("the glucose reference band control is translated everywhere", () => {
expect(copy).toContain("never inferred");
});
});
/**
* 4. THE MOOD PROGNOSIS. The forecast is a comparison with somebody's own past
* days and it may never be phrased as a cause. "A value around 5.4 would
* have been expected" is what the arithmetic supports; "overtime lowered
* your mood" is not, and the difference is not a matter of taste — the model
* is a correlation over a dozen columns and a causal sentence would be a
* claim it cannot make about somebody's health.
*
* The second half is the count. A forecast built from thirty days and one
* built from three hundred render identically, so every statement that
* quotes a value has to quote what it rests on. Both halves are a test here
* rather than a review habit, because a review habit does not survive the
* fifth locale.
*/
describe("the mood prognosis never states a cause", () => {
/**
* Causal constructions, per locale. Multi-word where a single word would
* over-match — Italian `causa` is a noun in ordinary use and only `a causa
* di` is the construction being banned.
*/
const CAUSAL_PHRASES: Record<string, readonly string[]> = {
de: [
"weil",
"verursacht",
"führt zu",
"sorgt für",
"wegen",
"verbessert deine",
"verschlechtert deine",
"senkt deine",
"hebt deine",
],
en: [
"because",
"causes",
"caused",
"leads to",
"due to",
"makes you",
"lowers your",
"raises your",
"improves your",
"worsens your",
],
es: [
"porque",
"provoca",
"debido a",
"a causa de",
"mejora tu",
"empeora tu",
],
fr: [
"parce que",
"provoque",
"en raison de",
"à cause de",
"améliore ton",
"dégrade ton",
],
it: [
"perché",
"provoca",
"a causa di",
"migliora il tuo",
"peggiora il tuo",
],
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
};
/** Every leaf string under `insights.mood.prognosis`, flattened. */
function prognosisStrings(locale: string): Array<[string, string]> {
const root = (
bundle(locale).insights as Record<string, Record<string, unknown>>
).mood.prognosis as Record<string, unknown>;
expect(
root,
`${locale} carries no insights.mood.prognosis block`,
).toBeTypeOf("object");
return Object.entries(root).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
);
}
for (const locale of LOCALES) {
it(`${locale} carries the block, and every string in it`, () => {
const strings = prognosisStrings(locale);
// The positive control. Every assertion below passes on an empty list,
// so the count is the evidence and the empty offender set is only the
// verdict.
expect(strings.length).toBeGreaterThanOrEqual(17);
for (const [key, value] of strings) {
expect(value.length, `${locale}/${key} is empty`).toBeGreaterThan(0);
}
});
it(`${locale} names no cause`, () => {
const offenders = prognosisStrings(locale)
.filter(([, value]) =>
CAUSAL_PHRASES[locale].some((phrase) =>
value.toLowerCase().includes(phrase),
),
)
.map(([key]) => key);
expect(
offenders,
`${locale} prognosis copy states a cause; the forecast is a comparison with the account's own past days and cannot support one`,
).toEqual([]);
});
}
/**
* Statements that quote a value must quote what it rests on. The list is
* explicit rather than derived, so adding a statement key forces a decision
* here instead of inheriting silence.
*/
const COUNTED_STATEMENTS: Record<string, readonly string[]> = {
statement: ["{value}", "{n}"],
learning: ["{entries}", "{threshold}"],
noPattern: ["{entries}"],
band: ["{low}", "{high}"],
};
for (const locale of LOCALES) {
it(`${locale} keeps the count beside the claim`, () => {
const strings = new Map(prognosisStrings(locale));
for (const [key, placeholders] of Object.entries(COUNTED_STATEMENTS)) {
const copy = strings.get(key);
expect(copy, `${locale}/${key} is missing`).toBeTypeOf("string");
for (const placeholder of placeholders) {
expect(
copy,
`${locale}/${key} quotes a value without ${placeholder}; a forecast without the days behind it cannot be weighed by the reader`,
).toContain(placeholder);
}
}
});
}
it("the forecast is phrased as a counterfactual, not as a measurement (EN + DE)", () => {
const en = resolve(bundle("en"), "insights.mood.prognosis.statement");
expect(en.toLowerCase()).toContain("would have been expected");
const de = resolve(bundle("de"), "insights.mood.prognosis.statement");
expect(de.toLowerCase()).toContain("zu erwarten gewesen");
});
});
@@ -381,6 +381,26 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["AlertDialog"],
},
// ── Visits and the address book ─────────────────────────────────────────
{
file: "hooks/use-encounters.ts",
destroys:
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
triggers: ["components/encounters/encounter-sheet.tsx"],
// The route soft-deletes and a restore route exists, but nothing in the
// product reaches it yet, so from a person's side the visit is gone. This
// becomes "restorable" the day a surface offers the undo, not before.
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "hooks/use-practitioners.ts",
destroys: "one address-book entry",
triggers: ["components/practitioners/practitioner-list.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
// ── Cycle ───────────────────────────────────────────────────────────────
{
file: "components/cycle/use-cycle.ts",
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(91);
expect(ALL_LEAF_IDS.length).toBe(92);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
});
it("places every leaf in exactly one group", () => {
@@ -0,0 +1,158 @@
/**
* `MoodPrediction` has exactly one writer, and it is not a request.
*
* The product rule underneath this guard: a day carries two readings — the
* one the person gave it and the one their own past days imply — and they are
* never merged. The self-assessment leads and is stored exactly as it was
* given; the prognosis is computed, carries a band and a case count, and the
* user cannot overwrite it. A route that wrote `moodPrediction` would make the
* second value editable, and an editable prognosis is not a prognosis: the
* deviation between the two, which is the only thing either number is for,
* would quietly become a number somebody could tune to zero.
*
* The second failure the single writer prevents is the one the cycle module
* already paid for: a forecast written as a side effect of a page view makes
* the output a function of browsing rather than of data
* (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`).
*
* **Every matcher here asserts a non-zero match count.** Legs (a) and (b)
* expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look
* identical from the outside — which is precisely how the bearer-scope guard
* stayed green for months while matching nothing at all. Each leg therefore
* pins a separate positive: the number of files SCANNED, and the write calls
* the legitimate writer really does make. `describe("the matcher sees what it
* looks for")` proves the regex against synthetic sources, including the
* broken-across-lines form that defeated an earlier guard in this repository.
*
* **What this guard structurally cannot see, stated like the session-surface
* guard states its own limit.** The matcher keys on a literal `moodPrediction`
* delegate access. The "Delete All Data" wipe DOES delete this table — the
* model is in `WIPE_MODELS` (`src/lib/data-wipe/wipe-plan.ts`) — but it does so
* through a delegate resolved dynamically from the model name (`client[key]
* .deleteMany`), so no `moodPrediction.deleteMany(` literal exists for the
* matcher to catch, and that deletion is correctly NOT counted as a writer.
* The wipe path's own completeness is proven elsewhere, by
* `data-wipe-completeness.test.ts` enumerating the schema; this guard is only
* the one about request-time writers, and it cannot be the gate on a dynamic
* delegate. A future writer that reached the table through a computed property
* would slip this matcher for the same reason.
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, sep } from "node:path";
import { describe, expect, it } from "vitest";
const SRC = join(process.cwd(), "src");
/**
* A write against the `moodPrediction` delegate, on any receiver.
*
* Whitespace-tolerant in every joint: Prettier breaks a long call across
* lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line
* is a matcher that stops matching the day the line gets long enough. The
* receiver is left open (`prisma`, `tx`, `db`, the worker client) because
* pinning one name would let a rename walk straight past.
*/
const WRITE_CALL =
/\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g;
/** The one file allowed to write, relative to `src/`. */
const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts");
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "generated" || entry.name === "node_modules") continue;
out.push(...sourceFiles(full));
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
function writeCallsIn(source: string): string[] {
return [...source.matchAll(WRITE_CALL)].map((m) => m[1]);
}
const ALL_FILES = sourceFiles(SRC);
const PRODUCTION_FILES = ALL_FILES.filter(
(f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"),
);
describe("MoodPrediction is written by one file", () => {
it("scans a plausible number of source files", () => {
// The positive control for legs (a) and (b) below, both of which pass on
// an empty result. If this collapses, they are asserting nothing.
expect(ALL_FILES.length).toBeGreaterThan(1000);
expect(PRODUCTION_FILES.length).toBeGreaterThan(800);
});
it("(a) no route, page or component writes it", () => {
const offenders = PRODUCTION_FILES.filter(
(f) =>
f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")),
).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0);
expect(
offenders.map((f) => f.slice(SRC.length + 1)),
"a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it",
).toEqual([]);
});
it("(b) the writer set is exactly the refresh job", () => {
const writers = PRODUCTION_FILES.filter(
(f) => writeCallsIn(readFileSync(f, "utf8")).length > 0,
).map((f) => f.slice(SRC.length + 1));
expect(writers.sort()).toEqual([SOLE_WRITER]);
});
it("(c) that writer really does write — the matcher is not matching nothing", () => {
expect(
existsSync(join(SRC, SOLE_WRITER)),
`${SOLE_WRITER} does not exist — the table has a guard and no writer`,
).toBe(true);
const source = readFileSync(join(SRC, SOLE_WRITER), "utf8");
const calls = writeCallsIn(source);
expect(
calls.length,
"the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth",
).toBeGreaterThan(0);
// It replaces rather than accumulates: a pass writes the current window
// and clears whatever fell out of it.
expect(calls).toContain("deleteMany");
});
});
describe("the matcher sees what it looks for", () => {
it("matches a call Prettier broke across lines", () => {
const wrapped = [
"await prisma.moodPrediction",
" .upsert(",
" { where: { userId_date: { userId, date } } },",
" );",
].join("\n");
expect(writeCallsIn(wrapped)).toEqual(["upsert"]);
});
it("matches through an aliased client", () => {
expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([
"deleteMany",
]);
});
it("does not match a read", () => {
expect(
writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"),
).toEqual([]);
});
it("does not match a different model", () => {
expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]);
});
});
@@ -44,12 +44,44 @@ describe("shared-record navigation", () => {
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
const navigation = resolveSharedRecordNavigation(["profile"]);
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
expect(navigation.destinationHrefs).toEqual(["/profile"]);
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([
"/profile",
"/checkups",
]);
expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]);
expect(navigation.allowsPath("/profile")).toBe(true);
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
});
it("presents the checkups page under both domains whose content it shows", () => {
// The page holds two things: the preventive-care list, which is a
// `measurements` read, and the visits section, which is `profile`. A grant
// naming either one must be able to reach it; a grant naming neither must
// not. Before the visits section the `measurements` omission was invisible,
// because a whole-record grant admits every path regardless.
expect(
resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"),
).toBe(true);
expect(
resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"),
).toBe(true);
expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe(
true,
);
expect(
resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"),
).toBe(false);
// Listed twice, offered once: a grant holding both domains must not put
// two identical entries in the navigation.
expect(
resolveSharedRecordNavigation([
"measurements",
"profile",
]).destinationHrefs.filter((href) => href === "/checkups"),
).toEqual(["/checkups"]);
});
it("uses the active record scope instead of the actor's module flags", () => {
const actorModules = {
medications: false,
+14 -2
View File
@@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "record",
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.",
},
"app/api/mood/prognosis/route.ts": {
domain: "record",
why: "The day's two readings — the record's own rating and what its past days imply — read-only. It is NOT a mind read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and the payload names the features that carried weight, so a reader sees that sleep or steps or resting heart rate mattered on a given day. Declared `mind` it would hand a mood-only delegate a derived view of sections the owner declined to share. Admitted on the whole-record grant only, like the linked-day read beside it; a scoped grant is refused rather than served a filtered contribution list, which would falsely read as \"the model weighted only the mood fields\".",
},
"app/api/mood-entries/[id]/route.ts": {
domain: "mind",
why: "One mood entry of the record, fetch-then-guard against the resolved user.",
@@ -935,6 +939,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
},
"app/api/encounters/suggest/route.ts": {
domain: "profile",
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1860,9 +1868,13 @@ const ACTOR_ROUTES: Record<string, string> = {
* managed-record configuration adds one more: 196 → 197. The bounded
* read-only profile summary makes the next explicit admission: 197 → 198.
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
* The mood day's linked figures, read-only, add one: 200 → 201.
* The mood day's linked figures, read-only, add one: 200 -> 201. The visit
* record and its address book bring twelve: 201 -> 213. The mood prognosis,
* read-only and admitted on the whole record for the same reason as the linked
* figures, adds one: 213 -> 214. The visit-suggestion read, which serves the
* three incidental-linking moments from the same rule, adds one: 214 -> 215.
*/
const FROZEN_ENTRY_COUNT = 213;
const FROZEN_ENTRY_COUNT = 215;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —