feat(mood): show the day's own rating beside what the account's days imply

Two readings, side by side, never merged. The person's own rating leads
— larger, first, and returned exactly as it was stored — and the
comparison sits beside it with its range and the number of days it was
built from. The deviation is measured against the range, not against its
midpoint: inside it is a day that went as the account's own past days
suggest, and only outside it is worth a sentence.

The sentence is a counterfactual and says so. Never "your mood is 5.4",
always "a value around 5.4 would have been expected". The explanation
names what the comparison weighted for that day, out of what the job
stored for that day, in the words the capture sheet already uses — a
person reads "Workload" here because that is what the slider they moved
was called.

The route declares the whole record rather than the mind section, and
the reason is written at the declaration: the comparison reads the day's
context AND the figures the sleep, activity and recovery modules hold,
and it names them, so a delegate holding only the mind section would
receive a derived view of sections the owner declined to share. A scoped
grant is refused outright rather than served a contribution list with
the linked features quietly removed, which would read as "only the mood
fields mattered" and be false.

The causal-verb ban is a test now rather than a review habit: the copy
guard reads all six bundles and fails on a causal construction in any of
them, and on a statement that quotes a value without the days behind it.
Both halves were watched failing.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 11:43:58 +02:00
1 parent 244e8ffbc7
commit f48afed82d
15 files changed
+1053 -4

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");
});
});
+8 -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.",
@@ -1812,9 +1816,11 @@ 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 mood
* prognosis, read-only and admitted on the whole record for the same reason,
* adds one: 201 → 202.
*/
const FROZEN_ENTRY_COUNT = 201;
const FROZEN_ENTRY_COUNT = 202;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —