From a118d4810d8deec0077e432569c3f22c2073fbb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 11:10:25 +0200 Subject: [PATCH 01/23] feat(mood): add the prediction table, with one writer and no writer yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MoodPrediction` holds the expected value of a day beside the day's own rating: one row per account per local day, with a band and the number of days the fit used. Two tables rather than four columns on the entry, because the point of the second value is that the person cannot correct it into agreement with the first — the distance between them is the finding, and a value the edit form can write is not a second reading. The table stays out of the disaster-recovery backup and the reason is written where the completeness guard reads it: every row is recomputable from the entries, the contexts and the linked modules, and carrying one would restore a forecast made by a model version that may no longer exist against data the restore has just moved. It is in the wipe plan, because a derived row is still a day-by-day statement about somebody. The write-surface guard lands red on purpose and names what is missing: `lib/jobs/mood-prognosis-refresh.ts` does not exist yet, so the writer set is empty where it should hold exactly that one file. The job closes it. The rest of the guard is green now — no route, page or component writes the table, and the matcher proves itself against a call broken across lines, an aliased client, a read and a different model. --- .../0312_mood_prediction/migration.sql | 58 +++++++ prisma/schema.prisma | 63 ++++++++ ...ood-prediction-write-surface-guard.test.ts | 145 ++++++++++++++++++ src/lib/data-wipe/wipe-plan.ts | 6 + src/lib/export/backup-plan.ts | 2 + 5 files changed, 274 insertions(+) create mode 100644 prisma/migrations/0312_mood_prediction/migration.sql create mode 100644 src/__tests__/mood-prediction-write-surface-guard.test.ts diff --git a/prisma/migrations/0312_mood_prediction/migration.sql b/prisma/migrations/0312_mood_prediction/migration.sql new file mode 100644 index 000000000..941713ec1 --- /dev/null +++ b/prisma/migrations/0312_mood_prediction/migration.sql @@ -0,0 +1,58 @@ +-- v1.38 — the expected value of a day, beside the day's own rating. +-- +-- One row per account per local day, holding what the account's own past days +-- say a day like this one usually looks like. It is a second reading of a day, +-- never a correction of the first: the self-assessment on `mood_entries` is +-- stored exactly as the person set it and nothing here touches it. The +-- distance between the two is the whole point of having both. +-- +-- Its own table rather than four more columns on `mood_entries`, and the +-- reason is a permission rather than a shape: a value the user must not be +-- able to overwrite has no business sitting in the row their own edit form +-- writes. One writer, one table. +-- +-- Not carried in a disaster-recovery backup. Every value here is recomputable +-- from `mood_entries` + `mood_contexts` and the measurement tables, and the +-- nightly job rebuilds it. The verdict is written down in `DERIVED_MODELS` +-- (`src/lib/export/backup-plan.ts`) where the completeness guard reads it, +-- so a restore that comes back without these rows is doing what it was told. +-- +-- No CHECK constraints on the ranges, matching `mood_contexts`: the bounds are +-- server-side, and a later change to a scale must not strand rows written +-- under the old one. + +CREATE TABLE "mood_predictions" ( + "user_id" TEXT NOT NULL, + -- YYYY-MM-DD in the account's own zone, the same key `mood_entries.date` + -- carries, so the two readings of one day join without a second timezone + -- derivation. + "date" TEXT NOT NULL, + + -- The expected pleasantness (A1), 0-10, and the band around it. The band + -- comes from the rolling-origin validation residuals, so it widens when + -- the model is bad instead of staying confidently narrow. + "predicted" DOUBLE PRECISION NOT NULL, + "ci_low" DOUBLE PRECISION, + "ci_high" DOUBLE PRECISION, + -- Complete days the fit was built from. Displayed wherever the value is. + "n" INTEGER NOT NULL, + + -- Bumped when the feature set or the fit changes, so a re-fit is legible + -- in the stored rows rather than silently changing what they mean. + "model_version" TEXT NOT NULL, + -- JSON array of { feature, contribution }: the standardised coefficient + -- times that day's standardised value. Stored so the explanation line is + -- generated from what the model actually used. + "features" TEXT NOT NULL, + "computed_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "mood_predictions_pkey" PRIMARY KEY ("user_id", "date") +); + +-- The account cascade every other user-scoped table here carries. The primary +-- key already leads with `user_id`, so the per-account sweep the job and the +-- wipe both run needs no second index. +ALTER TABLE "mood_predictions" + ADD CONSTRAINT "mood_predictions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92cb5c569..24eae540f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -774,6 +774,7 @@ model User { googleHealthOAuthStates GoogleHealthOAuthState[] moodEntries MoodEntry[] moodContexts MoodContext[] + moodPredictions MoodPrediction[] mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") @@ -4256,6 +4257,68 @@ model MoodContext { @@map("mood_contexts") } +/// What the account's own past days say a day like this one usually looks +/// like — one row per local day, written by a job and never by a person. +/// +/// Four things about this table that a later reader would otherwise have to +/// infer, and would infer wrongly: +/// +/// 1. **Written by the `mood-prognosis-refresh` job only. Never by a route, +/// and the user cannot overwrite it.** The self-assessment on `MoodEntry` +/// and the value here are two separate readings of the same day and they +/// stay separate: the deviation between them is the entire finding, and a +/// surface that let somebody correct the prognosis into agreement would +/// destroy it. Two tables make that structural rather than a convention, +/// and `src/__tests__/mood-prediction-write-surface-guard.test.ts` holds +/// the writer set to one file. +/// 2. **`features` holds the evidence, not a hint.** It is the JSON list of +/// the standardised contributions the fit actually used for that day, so +/// the "what the model weighted" line is generated from what happened +/// rather than recomputed at render time against a model that may since +/// have been re-fitted. +/// 3. **Deliberately not in the disaster-recovery backup.** It is a derived +/// artefact, recomputable in full from `MoodEntry` + `MoodContext` and the +/// linked modules, and the nightly job rebuilds it after a restore. The +/// verdict is written down in `DERIVED_MODELS` +/// (`src/lib/export/backup-plan.ts`), where the completeness guard reads +/// it, so this is a decision rather than an omission. It IS in the wipe +/// plan: a derived row is still a statement about somebody's days. +/// 4. **`n` and the band are part of the value.** A consumer that renders +/// `predicted` without `ciLow` / `ciHigh` / `n` is a defect: a forecast +/// built from thirty days and one built from three hundred look identical +/// on screen and mean very different things. +model MoodPrediction { + userId String @map("user_id") + /// YYYY-MM-DD in the account's own zone — the same day key and the same + /// anchoring `MoodEntry.date` carries, so the two readings of a day join + /// without a second timezone derivation. + date String + + /// The expected pleasantness (A1) for that day, 0-10, on the account's own + /// past pattern. A counterfactual, never a measurement. + predicted Float + /// The band around it, from the rolling-origin validation residuals. Null + /// only when the validation produced no residuals at all, which the write + /// path treats as "no forecast" rather than as a bare point estimate. + ciLow Float? @map("ci_low") + ciHigh Float? @map("ci_high") + /// Complete days the fit was built from. Shown wherever `predicted` is. + n Int + + /// Bumped whenever the feature set or the fit changes, so a re-fit is + /// legible in the stored rows instead of silently changing their meaning. + modelVersion String @map("model_version") + /// JSON array of `{ feature, contribution }` — the standardised coefficient + /// times that day's standardised value, per feature. + features String + computedAt DateTime @map("computed_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, date]) + @@map("mood_predictions") +} + // ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ──────────────────────── // // Structured, icon-bearing tag catalog modelled on the standalone mood diff --git a/src/__tests__/mood-prediction-write-surface-guard.test.ts b/src/__tests__/mood-prediction-write-surface-guard.test.ts new file mode 100644 index 000000000..3bacd4a4f --- /dev/null +++ b/src/__tests__/mood-prediction-write-surface-guard.test.ts @@ -0,0 +1,145 @@ +/** + * `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. + */ +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([]); + }); +}); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 0d8441dac..c62c6f8a1 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -75,6 +75,12 @@ export const WIPE_MODELS = [ // but a parent deleted first leaves the child's own count reporting zero for // rows that existed, and the confirmation screen quotes those counts. "MoodContext", + // Derived, and wiped anyway. The forecast rows are recomputable from the + // entries above — which is why they stay out of the backup — but they are + // still a day-by-day statement about somebody's mood, and leaving them + // behind after a screen that promised to delete all health data would leave + // the shape of the record standing where the record used to be. + "MoodPrediction", "MoodEntry", "MoodEntryRollup", "MoodReminderDispatch", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 84bc08a66..8462a2f14 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -342,6 +342,8 @@ export const DERIVED_MODELS: Readonly> = { "Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.", MoodEntryRollup: "A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.", + MoodPrediction: + "What the account's own past days imply about a day, fitted from `MoodEntry` + `MoodContext` and the linked modules. The nightly `mood-prognosis-refresh` job rebuilds it after a restore, and rebuilding it is better than carrying it: a restored row would state a forecast made by a model version that may no longer exist, against data the restore has just moved.", CyclePrediction: "A forecast derived from `MenstrualCycle` history; regenerated on the next cycle read.", StrainTrimpCache: From 0aeb63cbeb1ccb523a2c2a06cad64cc903c3dea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 11:15:00 +0200 Subject: [PATCH 02/23] feat(mood): the minimum-n ladder, as constants with boundary tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five rungs — nothing at all below fifteen entries, descriptive hints to thirty, a provisional forecast to sixty, a plain one to ninety, weekday and seasonal comparisons above it — plus the occurrence floor a value must clear before it may be named at all. Every floor is an exported constant and every one has a test on both sides of its own boundary, because the failure here is invisible: a forecast built from eight days renders exactly like one built from eighty, and nobody reading the screen can tell. Each assertion names the threshold it is about, so a red says which floor moved. Absence carries its reason and the count rather than being a bare false, so a surface can say how far along the account is instead of showing an error. Two numbers are deliberately not minted here: the paired-value floor stays `MIN_PAIRED_N` in the correlations module, and the occurrence floor is the crosstab's own, taken by assignment. A test reads the file back and fails if either turns into a local copy. --- .../mood-prognosis/__tests__/gates.test.ts | 224 ++++++++++++++++++ .../analytics/mood-prognosis/thresholds.ts | 166 +++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 src/lib/analytics/mood-prognosis/__tests__/gates.test.ts create mode 100644 src/lib/analytics/mood-prognosis/thresholds.ts diff --git a/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts new file mode 100644 index 000000000..40be4d6b7 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts @@ -0,0 +1,224 @@ +/** + * The minimum-n ladder, at every boundary, from both sides. + * + * Each case names the threshold it is about, so a red here says which floor + * moved rather than "expected false to be true". That matters more than usual: + * the thing these gates prevent is invisible on screen, so the test output is + * the only place the failure is ever legible. + * + * Every one of these has been watched failing — the comparison flipped from + * `>=` to `>`, the red read, the file restored and the hash checked — and one + * check was deleted outright to prove the absence of a gate fails too. The log + * is in the phase's execution notes. + * + * The assertions name the REASON, not just the absence: "no forecast" and "no + * forecast because there are only eleven entries and fifteen is where anything + * starts" are different answers, and only the second one can be shown to + * somebody. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + MIN_ENTRIES_FOR_FORECAST, + MIN_ENTRIES_FOR_REGULAR_FORECAST, + MIN_ENTRIES_FOR_SEASONAL, + MIN_ENTRIES_LEARNING_PHASE, + MIN_TAG_OCCURRENCES, + MIN_WEEKDAY_OBSERVATIONS, + forecastGate, + occurrencesSuffice, + prognosisStage, + seasonalComparisonsUnlocked, + weekdayObservationsSuffice, +} from "../thresholds"; +import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab"; + +describe("the ladder is ordered and its rungs are the documented numbers", () => { + it("holds the values the concept asks for", () => { + expect(MIN_ENTRIES_LEARNING_PHASE).toBe(15); + expect(MIN_ENTRIES_FOR_FORECAST).toBe(30); + expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBe(60); + expect(MIN_ENTRIES_FOR_SEASONAL).toBe(90); + expect(MIN_WEEKDAY_OBSERVATIONS).toBe(4); + }); + + it("rises strictly, so no rung can be reached before the one below it", () => { + expect(MIN_ENTRIES_LEARNING_PHASE).toBeLessThan(MIN_ENTRIES_FOR_FORECAST); + expect(MIN_ENTRIES_FOR_FORECAST).toBeLessThan( + MIN_ENTRIES_FOR_REGULAR_FORECAST, + ); + expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBeLessThan( + MIN_ENTRIES_FOR_SEASONAL, + ); + }); +}); + +describe("MIN_ENTRIES_LEARNING_PHASE — below it, nothing at all", () => { + it(`refuses at ${MIN_ENTRIES_LEARNING_PHASE - 1}, and says why`, () => { + const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE - 1); + expect(gate.present, "MIN_ENTRIES_LEARNING_PHASE let n-1 through").toBe( + false, + ); + expect(gate).toEqual({ + present: false, + reason: "no-output-yet", + stage: "insufficient", + entries: MIN_ENTRIES_LEARNING_PHASE - 1, + nextThreshold: MIN_ENTRIES_LEARNING_PHASE, + }); + }); + + it(`reaches the learning phase at exactly ${MIN_ENTRIES_LEARNING_PHASE}`, () => { + const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE); + expect( + gate, + "MIN_ENTRIES_LEARNING_PHASE did not admit its own value", + ).toEqual({ + present: false, + reason: "learning-phase", + stage: "learning", + entries: MIN_ENTRIES_LEARNING_PHASE, + nextThreshold: MIN_ENTRIES_FOR_FORECAST, + }); + }); + + it("an empty account is absence, never a zero", () => { + const gate = forecastGate(0); + expect(gate.present).toBe(false); + expect(gate).not.toHaveProperty("predicted"); + if (!gate.present) expect(gate.entries).toBe(0); + }); +}); + +describe("MIN_ENTRIES_FOR_FORECAST — below it, no forecast", () => { + it(`refuses at ${MIN_ENTRIES_FOR_FORECAST - 1}, naming the learning phase`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST - 1); + expect(gate.present, "MIN_ENTRIES_FOR_FORECAST let n-1 through").toBe( + false, + ); + if (!gate.present) { + expect(gate.reason).toBe("learning-phase"); + expect(gate.nextThreshold).toBe(MIN_ENTRIES_FOR_FORECAST); + expect(gate.entries).toBe(MIN_ENTRIES_FOR_FORECAST - 1); + } + }); + + it(`forecasts at exactly ${MIN_ENTRIES_FOR_FORECAST}, labelled provisional`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST); + expect(gate.present, "MIN_ENTRIES_FOR_FORECAST refused its own value").toBe( + true, + ); + if (gate.present) { + expect(gate.stage).toBe("provisional"); + expect(gate.provisional).toBe(true); + } + }); +}); + +describe("MIN_ENTRIES_FOR_REGULAR_FORECAST — below it, provisional", () => { + it(`is still provisional at ${MIN_ENTRIES_FOR_REGULAR_FORECAST - 1}`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST - 1); + expect(gate.present).toBe(true); + if (gate.present) { + expect( + gate.provisional, + "MIN_ENTRIES_FOR_REGULAR_FORECAST dropped the provisional label at n-1", + ).toBe(true); + expect(gate.stage).toBe("provisional"); + } + }); + + it(`drops the label at exactly ${MIN_ENTRIES_FOR_REGULAR_FORECAST}`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST); + expect(gate.present).toBe(true); + if (gate.present) { + expect( + gate.provisional, + "MIN_ENTRIES_FOR_REGULAR_FORECAST kept the provisional label at its own value", + ).toBe(false); + expect(gate.stage).toBe("regular"); + } + }); +}); + +describe("MIN_ENTRIES_FOR_SEASONAL — below it, no weekday or seasonal view", () => { + it(`refuses the comparison at ${MIN_ENTRIES_FOR_SEASONAL - 1}`, () => { + expect( + seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL - 1), + "MIN_ENTRIES_FOR_SEASONAL unlocked the comparison at n-1", + ).toBe(false); + expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL - 1)).toBe("regular"); + }); + + it(`unlocks it at exactly ${MIN_ENTRIES_FOR_SEASONAL}`, () => { + expect( + seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL), + "MIN_ENTRIES_FOR_SEASONAL refused its own value", + ).toBe(true); + expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL)).toBe("seasonal"); + }); +}); + +describe("MIN_WEEKDAY_OBSERVATIONS — a weekday needs a month of itself", () => { + it(`refuses at ${MIN_WEEKDAY_OBSERVATIONS - 1}`, () => { + expect( + weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS - 1), + "MIN_WEEKDAY_OBSERVATIONS admitted n-1 observations of one weekday", + ).toBe(false); + }); + + it(`admits at exactly ${MIN_WEEKDAY_OBSERVATIONS}`, () => { + expect( + weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS), + "MIN_WEEKDAY_OBSERVATIONS refused its own value", + ).toBe(true); + }); +}); + +describe("MIN_TAG_OCCURRENCES — a value seen four times is not evidence", () => { + it(`refuses at ${MIN_TAG_OCCURRENCES - 1}`, () => { + expect( + occurrencesSuffice(MIN_TAG_OCCURRENCES - 1), + "MIN_TAG_OCCURRENCES admitted a value seen n-1 times", + ).toBe(false); + }); + + it(`admits at exactly ${MIN_TAG_OCCURRENCES}`, () => { + expect( + occurrencesSuffice(MIN_TAG_OCCURRENCES), + "MIN_TAG_OCCURRENCES refused its own value", + ).toBe(true); + }); + + it("is the crosstab's floor rather than a second opinion about it", () => { + expect(MIN_TAG_OCCURRENCES).toBe(CONTEXT_MIN_PRESENT_DAYS); + }); +}); + +describe("no second number is minted for an idea that already has one", () => { + const THRESHOLDS = readFileSync(join(__dirname, "../thresholds.ts"), "utf8"); + + it("the file really is the one under test", () => { + // The positive control: the two checks below pass on an empty read, so + // each needs proof the file was found and holds what it claims to. + expect(THRESHOLDS.length).toBeGreaterThan(1000); + expect(THRESHOLDS).toContain("MIN_ENTRIES_FOR_SEASONAL"); + }); + + it("declares no paired-observation floor of its own", () => { + // `MIN_PAIRED_N` lives in `correlations.ts` and the screening imports it + // from there. A constant here named for the same idea would be a second + // answer to one question, and the two would drift. + expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRED/); + expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRS/); + }); + + it("takes the occurrence floor from the crosstab rather than restating it", () => { + expect(THRESHOLDS).toMatch( + /MIN_TAG_OCCURRENCES\s*=\s*CONTEXT_MIN_PRESENT_DAYS/, + ); + }); +}); diff --git a/src/lib/analytics/mood-prognosis/thresholds.ts b/src/lib/analytics/mood-prognosis/thresholds.ts new file mode 100644 index 000000000..1d0d8e973 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/thresholds.ts @@ -0,0 +1,166 @@ +/** + * How much history a prognosis needs before it is allowed to say anything. + * + * This is the most dangerous file in the feature, and it is dangerous for one + * reason: **the failure mode is silent.** A forecast built from eight days + * renders exactly like a forecast built from eighty. Nobody looking at the + * screen can tell the difference, and the person reading it is being told + * something about their own health record. So every floor is a named constant + * with a boundary test, and every one of those tests has been watched failing + * — a gate nobody has seen go red is a decoration, not a gate. + * + * The ladder has five rungs, and each rung says what the surface may claim: + * + * | entries | stage | what may be shown | + * |---------|---------------|------------------------------------------------| + * | 0-14 | `insufficient`| nothing beyond the self-assessment trend | + * | 15-29 | `learning` | descriptive hints, labelled as a learning phase | + * | 30-59 | `provisional` | a forecast, labelled provisional | + * | 60-89 | `regular` | a forecast without the provisional label | + * | 90+ | `seasonal` | plus weekday and seasonal comparisons | + * + * Absence is `{ present: false }` with a reason and the count, never `0` and + * never an empty forecast object with a null inside it. That shape is a + * contract elsewhere in the product already — the MCP surface means exactly + * "honestly not recorded" by it — and a surface that answered zero here would + * be claiming a bad day where the truth is that nothing can be said yet. + * + * **Two numbers are deliberately NOT minted here.** The paired-observation + * floor for a correlation is `MIN_PAIRED_N` in + * `src/lib/insights/correlations.ts`, and the screening in `features.ts` + * imports it from there; a second constant for the same idea is how two + * surfaces start disagreeing about what counts as enough. The occurrence floor + * below is the crosstab's own floor by assignment rather than by coincidence, + * for the same reason — Phase 2's context comparison says at its declaration + * that this file would import it, and this file does. + */ +import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab"; + +/** + * Below this, no output at all — not even a descriptive hint. + * + * Fifteen days of a person's life is not a pattern, and a surface that + * described one would be describing noise back to somebody who would + * reasonably believe it. + */ +export const MIN_ENTRIES_LEARNING_PHASE = 15; + +/** + * Below this, descriptive hints only, labelled as a learning phase. At or + * above it, a fitted forecast is allowed. + */ +export const MIN_ENTRIES_FOR_FORECAST = 30; + +/** Below this, a forecast is shown but labelled provisional. */ +export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60; + +/** Below this, no weekday and no seasonal comparison. */ +export const MIN_ENTRIES_FOR_SEASONAL = 90; + +/** + * How often a tag or a context value must have been recorded before it may + * appear in an explanation at all. + * + * The crosstab's floor, taken rather than re-declared: it is the same + * question ("has this been seen often enough to be worth a sentence") asked + * by a different surface, and two answers to it would drift. + */ +export const MIN_TAG_OCCURRENCES = CONTEXT_MIN_PRESENT_DAYS; + +/** + * Observations of one weekday before that weekday may be compared to the + * others. Four is a month of Mondays. + */ +export const MIN_WEEKDAY_OBSERVATIONS = 4; + +/** Where an account sits on the ladder. */ +export type PrognosisStage = + "insufficient" | "learning" | "provisional" | "regular" | "seasonal"; + +/** + * Why there is no forecast. The reason travels with the count and with the + * next rung, so a surface can say "eleven of fifteen" instead of an error. + */ +export type PrognosisAbsenceReason = "no-output-yet" | "learning-phase"; + +export interface PrognosisAbsent { + present: false; + reason: PrognosisAbsenceReason; + stage: Extract; + /** Qualifying entries the account has. */ + entries: number; + /** Entries at which the next thing becomes available. */ + nextThreshold: number; +} + +export interface PrognosisAvailable { + present: true; + stage: Extract; + entries: number; + /** The forecast carries a provisional label below the regular threshold. */ + provisional: boolean; +} + +export type PrognosisGate = PrognosisAbsent | PrognosisAvailable; + +/** + * Which rung this many qualifying entries reaches. + * + * Every comparison is `>=`, so the constant names the first value that + * qualifies rather than the last that does not. The boundary tests pin both + * sides of each one. + */ +export function prognosisStage(entries: number): PrognosisStage { + if (entries >= MIN_ENTRIES_FOR_SEASONAL) return "seasonal"; + if (entries >= MIN_ENTRIES_FOR_REGULAR_FORECAST) return "regular"; + if (entries >= MIN_ENTRIES_FOR_FORECAST) return "provisional"; + if (entries >= MIN_ENTRIES_LEARNING_PHASE) return "learning"; + return "insufficient"; +} + +/** + * Whether a forecast may be shown for an account with this many qualifying + * entries, and if not, why not and what would change it. + */ +export function forecastGate(entries: number): PrognosisGate { + const stage = prognosisStage(entries); + if (stage === "insufficient") { + return { + present: false, + reason: "no-output-yet", + stage, + entries, + nextThreshold: MIN_ENTRIES_LEARNING_PHASE, + }; + } + if (stage === "learning") { + return { + present: false, + reason: "learning-phase", + stage, + entries, + nextThreshold: MIN_ENTRIES_FOR_FORECAST, + }; + } + return { + present: true, + stage, + entries, + provisional: stage === "provisional", + }; +} + +/** Whether weekday and seasonal comparisons have unlocked for this account. */ +export function seasonalComparisonsUnlocked(entries: number): boolean { + return entries >= MIN_ENTRIES_FOR_SEASONAL; +} + +/** Whether one weekday has been seen often enough to be compared. */ +export function weekdayObservationsSuffice(observations: number): boolean { + return observations >= MIN_WEEKDAY_OBSERVATIONS; +} + +/** Whether a tag or context value has been recorded often enough to name. */ +export function occurrencesSuffice(occurrences: number): boolean { + return occurrences >= MIN_TAG_OCCURRENCES; +} From 64c077f7acac7a021cbd398b84115a072b6e270d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 11:25:34 +0200 Subject: [PATCH 03/23] feat(mood): build the day matrix and fit it without letting it see ahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The target is the day's own pleasantness; the inputs are the other four self-ratings, the numbers and closed vocabularies of the day's context, and the figures the sleep, activity and recovery modules already hold. Nothing is imputed. A day missing one of the selected inputs is a day the fit does not see, and a feature nobody recorded on most days never becomes a column in the first place — the coverage floor is what keeps the complete-case rule from quietly emptying the matrix. A day with no context row contributes no work-status column at all, rather than a zero that would read as "worked a regular day". Columns are standardised against the account's own window, so a person whose stress never passes six does not have six read as the maximum. Candidates are screened with the machinery the rest of the product already runs — Pearson at the shared paired floor, Benjamini-Hochberg at the shared FDR, the shared effect-size floor on the shrunk estimate — because a sweep over sixty features at raw p < 0.05 finds three things that are not there. The fit is a ridge solved in closed form, validated on expanding windows in date order. The training range ends exactly where the validation range starts, asserted on the fold indices and on the day keys, not on the resulting error: a leaking split passes an error check whenever the leak is small, which is when nobody looks. The band is the central 80% of the validation residuals, so it widens when the model is bad instead of staying confidently narrow. The window read is the linked resolver's own engine, extended to answer many days in one query rather than growing a second opinion about what a Tuesday looked like. --- .../mood-prognosis/__tests__/features.test.ts | 281 ++++++++++ .../__tests__/rolling-origin.test.ts | 269 ++++++++++ .../analytics/mood-prognosis/feature-keys.ts | 98 ++++ src/lib/analytics/mood-prognosis/features.ts | 489 ++++++++++++++++++ src/lib/analytics/mood-prognosis/ridge.ts | 173 +++++++ src/lib/analytics/mood-prognosis/validate.ts | 202 ++++++++ src/lib/mood/linked-context.ts | 173 ++++++- 7 files changed, 1684 insertions(+), 1 deletion(-) create mode 100644 src/lib/analytics/mood-prognosis/__tests__/features.test.ts create mode 100644 src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts create mode 100644 src/lib/analytics/mood-prognosis/feature-keys.ts create mode 100644 src/lib/analytics/mood-prognosis/features.ts create mode 100644 src/lib/analytics/mood-prognosis/ridge.ts create mode 100644 src/lib/analytics/mood-prognosis/validate.ts diff --git a/src/lib/analytics/mood-prognosis/__tests__/features.test.ts b/src/lib/analytics/mood-prognosis/__tests__/features.test.ts new file mode 100644 index 000000000..19379217c --- /dev/null +++ b/src/lib/analytics/mood-prognosis/__tests__/features.test.ts @@ -0,0 +1,281 @@ +/** + * The matrix: what becomes a column, what never does, and what happens to a + * day that is missing something. + * + * The cases that matter are the absences. A day with no context row must not + * contribute a zero to a work-status column, because zero there means "worked + * a regular day and not overtime" and the truth is "nobody said". That + * distinction is invisible in the fitted numbers and would quietly teach the + * model that every unlogged day was a day off. + */ +import { describe, expect, it } from "vitest"; + +import { + MAX_MODEL_FEATURES, + MIN_FEATURE_COVERAGE, + buildFeatureMatrix, + extractDayFeatures, + screenFeatures, + standardiseDay, + type PrognosisContextInput, + type PrognosisDayInput, +} from "../features"; +import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions"; + +const EMPTY_LINKED = { + sleepAsleep: null, + steps: null, + activeEnergy: null, + restingHeartRate: null, + heartRateVariability: null, +}; + +function context( + overrides: Partial = {}, +): PrognosisContextInput { + return { + workStatus: null, + workMinutes: null, + overtimeMinutes: null, + workLoad: null, + workSatisfaction: null, + contactCircles: [], + contactForm: null, + contactExtent: null, + contactQuality: null, + contactSupport: null, + leisureCategories: [], + leisureMinutes: null, + leisureJoy: null, + leisureRecovery: null, + eventType: null, + eventValence: null, + ...overrides, + }; +} + +function day( + index: number, + overrides: Partial = {}, +): PrognosisDayInput { + const date = new Date(Date.UTC(2026, 0, 1 + index)); + return { + day: date.toISOString().slice(0, 10), + a1: 5, + dimensions: {}, + context: null, + linked: { ...EMPTY_LINKED }, + ...overrides, + }; +} + +describe("extractDayFeatures", () => { + it("takes A2 through the dimension table's inverse flag, not inline", () => { + const a2 = MOOD_DIMENSIONS.find((d) => d.key === "a2"); + expect(a2?.inverse, "A2 is the inverse dimension this case is about").toBe( + true, + ); + const features = extractDayFeatures( + day(0, { dimensions: { a2: 8, a3: 8 } }), + ); + // Stress 8 is a heavy day, so on an up-is-better axis it reads as 2. + expect(features.get("dimension:a2")).toBe(2); + // A3 is not inverse and is carried literally. + expect(features.get("dimension:a3")).toBe(8); + }); + + it("never makes A1 an input", () => { + const features = extractDayFeatures( + day(0, { a1: 7, dimensions: { a1: 7, a2: 3 } }), + ); + expect(features.has("dimension:a1")).toBe(false); + }); + + it("a day with no context contributes no context column at all", () => { + const features = extractDayFeatures(day(0, { context: null })); + expect( + [...features.keys()].filter((k) => k.startsWith("context:")), + ).toEqual([]); + }); + + it("an unanswered field contributes no column; an answered one contributes zeros and a one", () => { + const answered = extractDayFeatures( + day(0, { context: context({ workStatus: "overtime" }) }), + ); + expect(answered.get("context:workStatus=overtime")).toBe(1); + expect(answered.get("context:workStatus=off")).toBe(0); + + const unanswered = extractDayFeatures( + day(0, { context: context({ workLoad: 4 }) }), + ); + expect(unanswered.has("context:workStatus=overtime")).toBe(false); + expect(unanswered.has("context:workStatus=off")).toBe(false); + expect(unanswered.get("context:workLoad")).toBe(4); + }); + + it("a recorded zero is a value, and absence is absence", () => { + const zero = extractDayFeatures( + day(0, { linked: { ...EMPTY_LINKED, steps: 0 } }), + ); + expect(zero.get("linked:steps")).toBe(0); + const absent = extractDayFeatures(day(0)); + expect(absent.has("linked:steps")).toBe(false); + }); + + it("an empty multi-select says nothing rather than saying no to everything", () => { + const features = extractDayFeatures( + day(0, { context: context({ contactCircles: [] }) }), + ); + expect(features.has("context:contactCircles=partner")).toBe(false); + }); +}); + +describe("screening", () => { + /** A2 drives A1 exactly; everything else is constant or noise. */ + function drivenDays(count: number): PrognosisDayInput[] { + return Array.from({ length: count }, (_, i) => { + const stress = i % 11; + return day(i, { + // Up-is-better A2 is `10 - stress`, and the target follows it. + a1: 10 - stress, + dimensions: { a2: stress }, + }); + }); + } + + it("keeps a feature that tracks the target", () => { + const screened = screenFeatures(drivenDays(40)); + const a2 = screened.find((s) => s.key === "dimension:a2"); + expect(a2?.selected, "a perfectly tracking feature was screened out").toBe( + true, + ); + expect(a2?.pairs).toBe(40); + }); + + it("refuses a feature recorded on too few of the days, before testing it", () => { + const days = drivenDays(40); + // Recorded on a quarter of the days — under the coverage floor whatever + // it correlates with. + const covered = Math.floor(40 * MIN_FEATURE_COVERAGE) - 5; + for (let i = 0; i < covered; i++) { + days[i] = { ...days[i], linked: { ...EMPTY_LINKED, steps: 1000 + i } }; + } + const screened = screenFeatures(days); + const steps = screened.find((s) => s.key === "linked:steps"); + expect(steps?.selected).toBe(false); + expect(steps?.reason).toBe("coverage"); + }); + + it("refuses a one-hot seen fewer times than the occurrence floor", () => { + const days = drivenDays(40).map((d, i) => ({ + ...d, + // Everybody worked; two days were overtime. The column is present on + // every day, so coverage passes and the occurrence floor is what + // refuses it. + context: context({ workStatus: i < 2 ? "overtime" : "regular" }), + })); + const screened = screenFeatures(days); + const overtime = screened.find( + (s) => s.key === "context:workStatus=overtime", + ); + expect(overtime?.selected).toBe(false); + expect(overtime?.reason).toBe("occurrences"); + }); + + it("refuses pure noise — the whole reason the FDR layer is here", () => { + // A deterministic pseudo-random series, so the case cannot flake. + let seed = 42; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + const days = Array.from({ length: 60 }, (_, i) => + day(i, { + a1: Math.round(random() * 10), + dimensions: { a2: Math.round(random() * 10) }, + linked: { ...EMPTY_LINKED, steps: Math.round(random() * 10000) }, + }), + ); + const selected = screenFeatures(days).filter((s) => s.selected); + expect(selected.map((s) => s.key)).toEqual([]); + }); + + it("caps the column count", () => { + expect(MAX_MODEL_FEATURES).toBe(12); + }); +}); + +describe("buildFeatureMatrix", () => { + function drivenDays(count: number): PrognosisDayInput[] { + return Array.from({ length: count }, (_, i) => + day(i, { a1: 10 - (i % 11), dimensions: { a2: i % 11 } }), + ); + } + + it("standardises per user, so the columns are that person's own units", () => { + const matrix = buildFeatureMatrix(drivenDays(40)); + expect(matrix).not.toBeNull(); + if (!matrix) return; + const column = matrix.rows.map((row) => row[0]); + const mean = column.reduce((s, v) => s + v, 0) / column.length; + const sd = Math.sqrt( + column.reduce((s, v) => s + (v - mean) * (v - mean), 0) / column.length, + ); + expect(mean).toBeCloseTo(0, 6); + expect(sd).toBeCloseTo(1, 6); + // The standardisation is kept so a later day can be scored on the same + // scale rather than re-standardised against itself. + expect(matrix.standardisation[0].key).toBe(matrix.features[0]); + expect(matrix.standardisation[0].sd).toBeGreaterThan(0); + }); + + it("orders rows oldest first, which is what makes the validation honest", () => { + const matrix = buildFeatureMatrix(drivenDays(40)); + expect(matrix).not.toBeNull(); + if (!matrix) return; + const sorted = [...matrix.days].sort(); + expect(matrix.days).toEqual(sorted); + }); + + it("drops a day that is missing a selected feature rather than filling it in", () => { + const days = drivenDays(40); + days[5] = { ...days[5], dimensions: {} }; + const matrix = buildFeatureMatrix(days); + expect(matrix).not.toBeNull(); + if (!matrix) return; + expect(matrix.days).not.toContain(days[5].day); + expect(matrix.rows).toHaveLength(39); + // Nothing was imputed in its place. + expect(matrix.targets).toHaveLength(39); + }); + + it("refuses when nothing survives the screening", () => { + const flat = Array.from({ length: 40 }, (_, i) => day(i, { a1: 5 })); + expect(buildFeatureMatrix(flat)).toBeNull(); + }); + + it("refuses a constant target instead of dividing by zero", () => { + const constant = Array.from({ length: 40 }, (_, i) => + day(i, { a1: 5, dimensions: { a2: i % 7 } }), + ); + expect(buildFeatureMatrix(constant)).toBeNull(); + }); +}); + +describe("standardiseDay", () => { + it("scores a day on the stored scale", () => { + const row = standardiseDay(day(0, { dimensions: { a2: 5 } }), [ + { key: "dimension:a2", mean: 5, sd: 2 }, + ]); + // Up-is-better A2 of a stress-5 day is 5, the mean, so zero. + expect(row).toEqual([0]); + }); + + it("refuses a day missing one of the fit's features", () => { + expect( + standardiseDay(day(0, { dimensions: {} }), [ + { key: "dimension:a2", mean: 5, sd: 2 }, + ]), + ).toBeNull(); + }); +}); diff --git a/src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts b/src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts new file mode 100644 index 000000000..53f93277a --- /dev/null +++ b/src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts @@ -0,0 +1,269 @@ +/** + * The fit, and the one property that makes its band worth reading. + * + * The leakage case is the reason this file exists. A random k-fold over days + * produces a model that scores beautifully and predicts nothing, and there is + * nothing in the reported error that says so — which is why the assertion is + * on the FOLD INDICES and on the day keys themselves rather than on the error. + * An error-based check passes a leaking split whenever the leak happens to be + * small, which is exactly when nobody would look. + * + * Watched red: the split was replaced with a shuffle, and + * `describe("no training fold contains a day after its validation fold")` + * went red naming a future day inside a training fold. Reverted, hash checked. + */ +import { describe, expect, it } from "vitest"; + +import { buildFeatureMatrix, type PrognosisDayInput } from "../features"; +import { fitRidge, predictWithFit, solveLinearSystem } from "../ridge"; +import { + BAND_LOWER_QUANTILE, + BAND_UPPER_QUANTILE, + LAMBDA_GRID, + MIN_VALIDATION_ROWS, + MODEL_VERSION, + fitPrognosisModel, + quantile, + rollingOriginFolds, +} from "../validate"; + +const EMPTY_LINKED = { + sleepAsleep: null, + steps: null, + activeEnergy: null, + restingHeartRate: null, + heartRateVariability: null, +}; + +function dayKey(index: number): string { + return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10); +} + +/** A deterministic pseudo-random series — a flaky statistics test is useless. */ +function seeded(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; +} + +/** + * Days whose target is a clean linear function of one dimension. + * + * One driver rather than two, and that is a statement about the screening + * rather than about the fit: the screening is univariate, so a feature that + * only matters jointly with another is refused before the fit ever sees it. + * A two-driver fixture measures that property, not this one. + */ +function linearDays(count: number): PrognosisDayInput[] { + return Array.from({ length: count }, (_, i) => { + const stress = i % 11; + return { + day: dayKey(i), + // 0.8 × up-is-better stress, plus one — inside the 0-10 scale. + a1: 0.8 * (10 - stress) + 1, + dimensions: { a2: stress, a3: (i * 7) % 11 }, + context: null, + linked: { ...EMPTY_LINKED }, + }; + }); +} + +/** Days whose target is noise and whose inputs are unrelated noise. */ +function noisyDays(count: number, seed = 7): PrognosisDayInput[] { + const random = seeded(seed); + return Array.from({ length: count }, (_, i) => ({ + day: dayKey(i), + a1: Math.round(random() * 10), + dimensions: { + a2: Math.round(random() * 10), + a3: Math.round(random() * 10), + }, + context: null, + linked: { ...EMPTY_LINKED }, + })); +} + +describe("no training fold contains a day after its validation fold", () => { + const ROWS = 60; + const folds = rollingOriginFolds(ROWS); + + it("produces folds at all — the assertions below pass on an empty list", () => { + expect(folds.length).toBeGreaterThan(0); + expect(folds.length).toBeLessThanOrEqual(4); + // Every validation row is covered exactly once and the last fold reaches + // the end, so no day is silently dropped from the measurement. + expect(folds[folds.length - 1].validationEnd).toBe(ROWS); + }); + + it("trains on a prefix that ends exactly where the validation starts", () => { + for (const fold of folds) { + expect(fold.trainEnd).toBe(fold.validationStart); + expect(fold.trainEnd).toBeGreaterThan(0); + expect(fold.validationEnd).toBeGreaterThan(fold.validationStart); + } + }); + + it("holds on the day keys, not just on the indices", () => { + // The indices are only meaningful because the matrix is time-ordered, so + // the property is asserted against the dates the rows actually carry. + const matrix = buildFeatureMatrix(linearDays(ROWS)); + expect(matrix).not.toBeNull(); + if (!matrix) return; + + for (const fold of rollingOriginFolds(matrix.rows.length)) { + const trainingDays = matrix.days.slice(0, fold.trainEnd); + const validationDays = matrix.days.slice( + fold.validationStart, + fold.validationEnd, + ); + const latestTrainingDay = trainingDays[trainingDays.length - 1]; + const earliestValidationDay = validationDays[0]; + const leaked = trainingDays.filter((d) => d >= earliestValidationDay); + expect( + leaked, + `a training fold holds ${leaked.length} day(s) dated at or after the validation fold that starts on ${earliestValidationDay}`, + ).toEqual([]); + expect(latestTrainingDay < earliestValidationDay).toBe(true); + } + }); + + it("expands rather than slides — later folds train on more history", () => { + const sizes = folds.map((f) => f.trainEnd); + for (let i = 1; i < sizes.length; i++) { + expect(sizes[i]).toBeGreaterThan(sizes[i - 1]); + } + }); + + it("refuses to split a window too short to measure anything on", () => { + expect(rollingOriginFolds(MIN_VALIDATION_ROWS - 1)).toEqual([]); + }); +}); + +describe("the ridge solve", () => { + it("recovers the coefficients of a clean linear signal", () => { + // y = 2*x1 - 3*x2, standardised columns, tiny penalty. + const rows: number[][] = []; + const targets: number[] = []; + for (let i = 0; i < 40; i++) { + const x1 = ((i % 7) - 3) / 2; + const x2 = ((i % 5) - 2) / 1.5; + rows.push([x1, x2]); + targets.push(2 * x1 - 3 * x2 + 5); + } + const fit = fitRidge(rows, targets, 1e-6); + expect(fit).not.toBeNull(); + if (!fit) return; + expect(fit.coefficients[0]).toBeCloseTo(2, 2); + expect(fit.coefficients[1]).toBeCloseTo(-3, 2); + // The intercept is the mean of the training targets, and the columns are + // centred against their own means, so a row at the column means predicts + // the average day exactly. + expect(predictWithFit(fit, fit.columnMeans)).toBeCloseTo(fit.intercept, 6); + // And every training row is predicted back to its own target. + rows.forEach((row, i) => { + expect(predictWithFit(fit, row)).toBeCloseTo(targets[i], 2); + }); + }); + + it("does not divide by zero on a constant target", () => { + const rows = Array.from({ length: 20 }, (_, i) => [i / 10, (i % 3) / 2]); + const fit = fitRidge(rows, new Array(20).fill(4), 1); + expect(fit).not.toBeNull(); + if (!fit) return; + expect(fit.coefficients.every(Number.isFinite)).toBe(true); + expect(fit.intercept).toBe(4); + expect(predictWithFit(fit, [0, 0])).toBeCloseTo(4, 6); + }); + + it("refuses a singular system rather than answering zeros", () => { + // Two identical columns and no penalty: the system has no unique answer, + // and a vector of zeros would look exactly like "these features do not + // matter". + expect( + solveLinearSystem( + [ + [1, 1], + [1, 1], + ], + [1, 1], + ), + ).toBeNull(); + }); + + it("penalises: a larger lambda pulls the coefficients towards zero", () => { + const rows = Array.from({ length: 30 }, (_, i) => [((i % 7) - 3) / 2]); + const targets = rows.map((r) => 4 * r[0] + 5); + const light = fitRidge(rows, targets, 0.01); + const heavy = fitRidge(rows, targets, 100); + expect(light).not.toBeNull(); + expect(heavy).not.toBeNull(); + if (!light || !heavy) return; + expect(Math.abs(heavy.coefficients[0])).toBeLessThan( + Math.abs(light.coefficients[0]), + ); + }); +}); + +describe("the band reacts to how wrong the model is", () => { + it("is narrow on a clean signal and wide on noise", () => { + const clean = buildFeatureMatrix(linearDays(80)); + expect(clean).not.toBeNull(); + if (!clean) return; + const cleanModel = fitPrognosisModel(clean); + expect(cleanModel).not.toBeNull(); + if (!cleanModel) return; + const cleanWidth = cleanModel.bandHigh - cleanModel.bandLow; + + // Noise cannot go through the screening — it is refused before a fit is + // attempted, which is the stronger answer. So the band's reaction is + // measured on the matrix directly, with the screening bypassed by handing + // the fit a matrix built from the noisy targets and the clean columns. + const noisy = buildFeatureMatrix(linearDays(80)); + expect(noisy).not.toBeNull(); + if (!noisy) return; + const random = seeded(11); + const noisyMatrix = { + ...noisy, + targets: noisy.targets.map(() => Math.round(random() * 10)), + }; + const noisyModel = fitPrognosisModel(noisyMatrix); + expect(noisyModel).not.toBeNull(); + if (!noisyModel) return; + const noisyWidth = noisyModel.bandHigh - noisyModel.bandLow; + + expect( + noisyWidth, + "the band did not widen on noise — a confident wrong number is the failure this band exists to prevent", + ).toBeGreaterThan(cleanWidth * 2); + expect(cleanWidth).toBeLessThan(2); + }); + + it("refuses noise at the screening, before any fit is attempted", () => { + expect(buildFeatureMatrix(noisyDays(80))).toBeNull(); + }); + + it("takes the band from the validation residuals, at the stated quantiles", () => { + const matrix = buildFeatureMatrix(linearDays(80)); + expect(matrix).not.toBeNull(); + if (!matrix) return; + const model = fitPrognosisModel(matrix); + expect(model).not.toBeNull(); + if (!model) return; + expect(model.bandLow).toBeLessThanOrEqual(model.bandHigh); + expect(BAND_LOWER_QUANTILE).toBe(0.1); + expect(BAND_UPPER_QUANTILE).toBe(0.9); + expect(model.n).toBe(matrix.rows.length); + expect(model.modelVersion).toBe(MODEL_VERSION); + expect(LAMBDA_GRID).toContain(model.fit.lambda); + // Never zero: an unpenalised solve memorises the training days. + expect(Math.min(...LAMBDA_GRID)).toBeGreaterThan(0); + }); + + it("quantile interpolates rather than rounding to a sample", () => { + expect(quantile([0, 10], 0.5)).toBe(5); + expect(quantile([1, 2, 3, 4], 0)).toBe(1); + expect(quantile([1, 2, 3, 4], 1)).toBe(4); + }); +}); diff --git a/src/lib/analytics/mood-prognosis/feature-keys.ts b/src/lib/analytics/mood-prognosis/feature-keys.ts new file mode 100644 index 000000000..3f51c71b5 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/feature-keys.ts @@ -0,0 +1,98 @@ +/** + * The names the model's inputs are known by, everywhere. + * + * One string per feature, stored in `MoodPrediction.features`, read back by + * the surface that names what the model weighted, and compared against by the + * tests. It is therefore a wire format in all but name: renaming a key + * silently orphans every stored row that used it, which is why the encoding is + * a function here rather than a template literal at four call sites. + * + * The shape is `:`, and the four families are the four places an + * input can come from: + * + * * `dimension:a2` — a level-A self-rating (A1 is the target) + * * `context:workLoad` — a numeric field of the day's context + * * `context:workStatus=off` — one value of a closed context vocabulary + * * `linked:steps` — a figure read from the module that owns it + * + * Nothing here resolves a label. The label is a locale lookup and this module + * is pure and bundle-safe, so the resolver lives beside the component that + * renders it and takes a parsed key from here. + */ +import type { MoodDimensionKey } from "@/lib/mood/dimensions"; + +/** The linked figures the model may read, and the day-figure each names. */ +export const LINKED_FEATURE_METRICS = [ + "sleepAsleep", + "steps", + "activeEnergy", + "restingHeartRate", + "heartRateVariability", +] as const; + +export type LinkedFeatureMetric = (typeof LINKED_FEATURE_METRICS)[number]; + +export type ParsedFeatureKey = + | { family: "dimension"; dimension: MoodDimensionKey } + | { family: "context"; field: string; value: null } + | { family: "context"; field: string; value: string } + | { family: "linked"; metric: LinkedFeatureMetric }; + +/** `dimension:a2` */ +export function dimensionFeatureKey(dimension: MoodDimensionKey): string { + return `dimension:${dimension}`; +} + +/** `context:workLoad` — a numeric field. */ +export function contextNumericFeatureKey(field: string): string { + return `context:${field}`; +} + +/** `context:workStatus=off` — one value of a closed vocabulary. */ +export function contextValueFeatureKey(field: string, value: string): string { + return `context:${field}=${value}`; +} + +/** `linked:steps` */ +export function linkedFeatureKey(metric: LinkedFeatureMetric): string { + return `linked:${metric}`; +} + +const DIMENSION_KEYS = new Set(["a1", "a2", "a3", "a4", "a5"]); +const LINKED_KEYS = new Set(LINKED_FEATURE_METRICS); + +/** + * Read a stored key back. + * + * `null` for anything this version does not recognise, and that case is real + * rather than defensive: a row written by an older `modelVersion` can name a + * feature the current build has dropped, and the surface has to skip it rather + * than render `undefined` at somebody. + */ +export function parseFeatureKey(key: string): ParsedFeatureKey | null { + const separator = key.indexOf(":"); + if (separator < 0) return null; + const family = key.slice(0, separator); + const rest = key.slice(separator + 1); + if (rest.length === 0) return null; + + if (family === "dimension") { + return DIMENSION_KEYS.has(rest) + ? { family: "dimension", dimension: rest as MoodDimensionKey } + : null; + } + if (family === "linked") { + return LINKED_KEYS.has(rest) + ? { family: "linked", metric: rest as LinkedFeatureMetric } + : null; + } + if (family === "context") { + const equals = rest.indexOf("="); + if (equals < 0) return { family: "context", field: rest, value: null }; + const field = rest.slice(0, equals); + const value = rest.slice(equals + 1); + if (field.length === 0 || value.length === 0) return null; + return { family: "context", field, value }; + } + return null; +} diff --git a/src/lib/analytics/mood-prognosis/features.ts b/src/lib/analytics/mood-prognosis/features.ts new file mode 100644 index 000000000..467b71236 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/features.ts @@ -0,0 +1,489 @@ +/** + * One row per day, one column per thing the day was made of. + * + * The target is the day's own pleasantness (A1) and everything else is an + * input: the other four level-A dimensions, the numbers and the closed + * vocabularies of the day's context, and the figures the sleep, activity and + * recovery modules already hold for that day. A1 is the only target on + * purpose — five targets would be five ways to be wrong and five bands to + * explain. + * + * Four rules this file is built on, each of which has a way of being got + * wrong that looks fine on screen: + * + * 1. **Nothing is imputed. Ever.** A day with a missing input is not a day + * with an average input. The stated rule is complete cases: a day enters + * the fit only if it carries the target and every SELECTED feature, and a + * feature only becomes a candidate if it was recorded on at least + * {@link MIN_FEATURE_COVERAGE} of the days that carry a target. The + * coverage floor is what keeps the complete-case rule from quietly + * emptying the matrix: without it, one field somebody filled in twice + * would exclude every other day they ever logged. + * 2. **Standardised per user, not min-max.** A person whose stress never + * exceeds six must not have six read as "maximum stress". The mean and the + * spread come from their own fit window, so every feature is expressed in + * that person's own units — which is also what makes the stored + * contributions comparable to each other. + * 3. **Screened before it is fitted, with the multiple-comparison correction + * the rest of the product already runs.** A sweep over sixty context + * features at raw p < 0.05 finds three things that are not there. Pearson + * with the shared `MIN_PAIRED_N` floor, Benjamini-Hochberg at the shared + * `FDR_Q`, and the shared effect-size floor on the shrunk estimate — all + * imported, none re-tuned here. + * 4. **Orientation comes from the table that owns it.** A2 is inverse and is + * flipped through `MOOD_DIMENSIONS[i].inverse`, never inline, so the chart + * and the model read one source. Context ratings are NOT flipped: no table + * states their orientation, and inventing one here would be exactly the + * second opinion the dimension table exists to prevent. It costs nothing + * mathematically — a flip only changes the sign of a coefficient, and the + * stored contribution is signed anyway. + */ +import { + EFFECT_SIZE_FLOOR, + FDR_Q, + benjaminiHochberg, + shrinkEstimate, +} from "@/lib/insights/correlation-discovery"; +import { + MAX_P_VALUE, + MIN_PAIRED_N, + pearson, +} from "@/lib/insights/correlations"; +import { + CONTACT_CIRCLE_KEYS, + CONTACT_EXTENT_KEYS, + CONTACT_FORM_KEYS, + EVENT_TYPE_KEYS, + LEISURE_CATEGORY_KEYS, + WORK_STATUS_KEYS, +} from "@/lib/mood/context-vocabulary"; +import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions"; +import type { MoodDimensionKey } from "@/lib/mood/dimensions"; + +import { + contextNumericFeatureKey, + contextValueFeatureKey, + dimensionFeatureKey, + linkedFeatureKey, + LINKED_FEATURE_METRICS, +} from "./feature-keys"; +import { occurrencesSuffice } from "./thresholds"; + +/** + * Most features the fit may carry. + * + * Twelve over thirty days is already generous — a ridge penalty is what makes + * that survivable at all — and the cap is what stops a well-logged account + * from being fitted with sixty columns and a hundred rows. + */ +export const MAX_MODEL_FEATURES = 12; + +/** + * Share of target-carrying days a feature must be recorded on to be a + * candidate at all. + * + * The other half of the complete-case rule. A field somebody filled in for a + * fortnight and then stopped is not a column of the matrix; it is a fortnight, + * and admitting it would silently discard every day outside that fortnight. + */ +export const MIN_FEATURE_COVERAGE = 0.6; + +/** The day's context as the model reads it. The note never comes here. */ +export interface PrognosisContextInput { + workStatus: string | null; + workMinutes: number | null; + overtimeMinutes: number | null; + workLoad: number | null; + workSatisfaction: number | null; + contactCircles: string[]; + contactForm: string | null; + contactExtent: string | null; + contactQuality: number | null; + contactSupport: number | null; + leisureCategories: string[]; + leisureMinutes: number | null; + leisureJoy: number | null; + leisureRecovery: number | null; + eventType: string | null; + eventValence: number | null; +} + +/** One day, as everything upstream of the fit has resolved it. */ +export interface PrognosisDayInput { + /** Local day key, `YYYY-MM-DD`. */ + day: string; + /** The target: the day's own pleasantness, 0-10. `null` days are not fitted. */ + a1: number | null; + /** The other four self-ratings, literal as the person set them. */ + dimensions: Partial>; + /** The day's stored context, or `null` when no section was opened. */ + context: PrognosisContextInput | null; + /** What the owning modules hold for the day. `null` is absence. */ + linked: { + sleepAsleep: number | null; + steps: number | null; + activeEnergy: number | null; + restingHeartRate: number | null; + heartRateVariability: number | null; + }; +} + +/** The numeric context fields, in capture order. */ +const CONTEXT_NUMERIC_FIELDS = [ + "workMinutes", + "overtimeMinutes", + "workLoad", + "workSatisfaction", + "contactQuality", + "contactSupport", + "leisureMinutes", + "leisureJoy", + "leisureRecovery", + "eventValence", +] as const satisfies readonly (keyof PrognosisContextInput)[]; + +/** The single-valued vocabularies, each expanded to one column per value. */ +const CONTEXT_SINGLE_VOCABULARIES = [ + { field: "workStatus", keys: WORK_STATUS_KEYS }, + { field: "contactForm", keys: CONTACT_FORM_KEYS }, + { field: "contactExtent", keys: CONTACT_EXTENT_KEYS }, + { field: "eventType", keys: EVENT_TYPE_KEYS }, +] as const; + +/** The multi-select vocabularies, same expansion. */ +const CONTEXT_MULTI_VOCABULARIES = [ + { field: "contactCircles", keys: CONTACT_CIRCLE_KEYS }, + { field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS }, +] as const; + +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +/** + * Every feature this day carries a value for. + * + * A feature the day says nothing about is ABSENT from the map — it is not + * zero. The difference is the whole of rule 1: a day with no context row has + * no work-status column at all, while a day whose work status is `off` has a + * zero in every other work-status column and a one in that one. + */ +export function extractDayFeatures( + day: PrognosisDayInput, +): Map { + const out = new Map(); + + for (const dimension of MOOD_DIMENSIONS) { + // A1 is the target, not an input. Fitting a value on itself would produce + // a perfect model of nothing. + if (dimension.key === "a1") continue; + const raw = day.dimensions[dimension.key]; + if (!isNumber(raw)) continue; + // The orientation flip goes through the table that declares it, so a + // dimension that becomes inverse later moves the chart and the model + // together. + out.set( + dimensionFeatureKey(dimension.key), + dimension.inverse ? dimension.max - raw : raw, + ); + } + + const context = day.context; + if (context) { + for (const field of CONTEXT_NUMERIC_FIELDS) { + const raw = context[field]; + if (isNumber(raw)) out.set(contextNumericFeatureKey(field), raw); + } + for (const { field, keys } of CONTEXT_SINGLE_VOCABULARIES) { + const value = context[field]; + // A field nobody answered contributes no columns. Zeroing them would + // tell the fit "not overtime" on a day nobody said anything about work. + if (typeof value !== "string" || value.length === 0) continue; + for (const key of keys) { + out.set(contextValueFeatureKey(field, key), value === key ? 1 : 0); + } + } + for (const { field, keys } of CONTEXT_MULTI_VOCABULARIES) { + const values = context[field]; + if (!Array.isArray(values) || values.length === 0) continue; + const chosen = new Set(values); + for (const key of keys) { + out.set(contextValueFeatureKey(field, key), chosen.has(key) ? 1 : 0); + } + } + } + + for (const metric of LINKED_FEATURE_METRICS) { + const raw = day.linked[metric]; + if (isNumber(raw)) out.set(linkedFeatureKey(metric), raw); + } + + return out; +} + +/** What the screening decided about one candidate, kept for the tests. */ +export interface ScreenedFeature { + key: string; + /** Days carrying both the feature and the target. */ + pairs: number; + r: number; + /** The shrunk estimate the effect floor is applied to. */ + shrunk: number; + pValue: number; + qValue: number; + selected: boolean; + /** Why it was refused, when it was. */ + reason?: + | "coverage" + | "too-few-pairs" + | "no-variance" + | "p-value" + | "fdr" + | "effect-size" + | "occurrences" + | "cap"; +} + +/** + * Which candidates earn a column, and why the others did not. + * + * Order of refusal matters and is the order below: coverage, then pairs, then + * the raw p, then the family-wide FDR, then the effect floor, then the cap. + * A feature that fails an earlier gate is not tested against a later one, so + * the family the FDR corrects over is the set that was actually tested. + */ +export function screenFeatures( + days: readonly PrognosisDayInput[], +): ScreenedFeature[] { + const targetDays = days.filter((d) => isNumber(d.a1)); + const perDay = new Map>(); + for (const day of targetDays) perDay.set(day.day, extractDayFeatures(day)); + + const candidates = new Set(); + for (const features of perDay.values()) { + for (const key of features.keys()) candidates.add(key); + } + + const coverageFloor = targetDays.length * MIN_FEATURE_COVERAGE; + const refused: ScreenedFeature[] = []; + const tested: Array<{ + key: string; + pairs: number; + r: number; + shrunk: number; + pValue: number; + }> = []; + + for (const key of [...candidates].sort()) { + const xs: number[] = []; + const ys: number[] = []; + let occurrences = 0; + for (const day of targetDays) { + const value = perDay.get(day.day)?.get(key); + if (value === undefined) continue; + xs.push(value); + ys.push(day.a1 as number); + if (value !== 0) occurrences += 1; + } + + if (xs.length < coverageFloor) { + refused.push(zero(key, xs.length, "coverage")); + continue; + } + // A one-hot column seen four times is not evidence, however well it + // correlates. This is the ladder's occurrence floor, not a second one. + if (!occurrencesSuffice(occurrences)) { + refused.push(zero(key, xs.length, "occurrences")); + continue; + } + + const result = pearson({ xs, ys, minPairs: MIN_PAIRED_N }); + if (result.status === "insufficient") { + refused.push( + zero( + key, + result.n, + result.reason === "no_variance" ? "no-variance" : "too-few-pairs", + ), + ); + continue; + } + if (result.pValue >= MAX_P_VALUE) { + refused.push({ + key, + pairs: result.n, + r: result.r, + shrunk: shrinkEstimate(result.r, result.n), + pValue: result.pValue, + qValue: 1, + selected: false, + reason: "p-value", + }); + continue; + } + tested.push({ + key, + pairs: result.n, + r: result.r, + shrunk: shrinkEstimate(result.r, result.n), + pValue: result.pValue, + }); + } + + const qValues = benjaminiHochberg(tested.map((t) => t.pValue)); + const survivors: ScreenedFeature[] = []; + tested.forEach((t, index) => { + const qValue = qValues[index] ?? 1; + if (qValue > FDR_Q) { + survivors.push({ ...t, qValue, selected: false, reason: "fdr" }); + return; + } + if (Math.abs(t.shrunk) < EFFECT_SIZE_FLOOR) { + survivors.push({ ...t, qValue, selected: false, reason: "effect-size" }); + return; + } + survivors.push({ ...t, qValue, selected: true }); + }); + + // The cap keeps the strongest, and a stable tie-break by key keeps two runs + // over the same data returning the same columns. + const admitted = survivors + .filter((s) => s.selected) + .sort( + (a, b) => + Math.abs(b.shrunk) - Math.abs(a.shrunk) || a.key.localeCompare(b.key), + ); + for (const feature of admitted.slice(MAX_MODEL_FEATURES)) { + feature.selected = false; + feature.reason = "cap"; + } + + return [...survivors, ...refused].sort((a, b) => a.key.localeCompare(b.key)); +} + +function zero( + key: string, + pairs: number, + reason: ScreenedFeature["reason"], +): ScreenedFeature { + return { + key, + pairs, + r: 0, + shrunk: 0, + pValue: 1, + qValue: 1, + selected: false, + reason, + }; +} + +/** Per-feature centre and spread, over this account's own fit window. */ +export interface FeatureStandardisation { + key: string; + mean: number; + sd: number; +} + +export interface FeatureMatrix { + /** The selected feature keys, in column order. */ + features: string[]; + /** Standardised rows, one per complete day, oldest first. */ + rows: number[][]; + /** The target for each row, unstandardised (0-10). */ + targets: number[]; + /** The day each row is, so the validation can stay time-ordered. */ + days: string[]; + /** What each column was standardised by, needed to score a new day. */ + standardisation: FeatureStandardisation[]; +} + +/** + * Build the matrix: screen, then keep the complete days, then standardise. + * + * Returns `null` when nothing survives the screening or when no day carries + * every surviving feature. That is a refusal, not an empty matrix — the caller + * writes no forecast rather than one built on nothing. + */ +export function buildFeatureMatrix( + days: readonly PrognosisDayInput[], +): FeatureMatrix | null { + const screened = screenFeatures(days); + const selected = screened.filter((s) => s.selected).map((s) => s.key); + if (selected.length === 0) return null; + + const ordered = [...days] + .filter((d) => isNumber(d.a1)) + .sort((a, b) => a.day.localeCompare(b.day)); + + const rawRows: number[][] = []; + const targets: number[] = []; + const dayKeys: string[] = []; + for (const day of ordered) { + const features = extractDayFeatures(day); + const row: number[] = []; + let complete = true; + for (const key of selected) { + const value = features.get(key); + if (value === undefined) { + complete = false; + break; + } + row.push(value); + } + // Complete cases only. A day missing one selected feature is a day the fit + // does not see; nothing is filled in for it. + if (!complete) continue; + rawRows.push(row); + targets.push(day.a1 as number); + dayKeys.push(day.day); + } + + if (rawRows.length === 0) return null; + + const standardisation = selected.map((key, column) => { + const values = rawRows.map((row) => row[column]); + const mean = values.reduce((sum, v) => sum + v, 0) / values.length; + const variance = + values.reduce((sum, v) => sum + (v - mean) * (v - mean), 0) / + values.length; + return { key, mean, sd: Math.sqrt(variance) }; + }); + + // A column with no spread inside the complete-case set carries no + // information and would divide by zero. It goes, and if that empties the + // set the whole fit is refused. + const keep = standardisation + .map((s, index) => ({ ...s, index })) + .filter((s) => s.sd > 0); + if (keep.length === 0) return null; + + return { + features: keep.map((k) => k.key), + rows: rawRows.map((row) => keep.map((k) => (row[k.index] - k.mean) / k.sd)), + targets, + days: dayKeys, + standardisation: keep.map(({ key, mean, sd }) => ({ key, mean, sd })), + }; +} + +/** + * Standardise one day's raw features against a stored fit, for scoring. + * + * `null` when the day does not carry every feature the fit uses — the same + * complete-case rule the matrix applies, asked of a single day. A day that + * cannot be scored gets no forecast, which is the honest answer. + */ +export function standardiseDay( + day: PrognosisDayInput, + standardisation: readonly FeatureStandardisation[], +): number[] | null { + const features = extractDayFeatures(day); + const row: number[] = []; + for (const { key, mean, sd } of standardisation) { + const value = features.get(key); + if (value === undefined || sd <= 0) return null; + row.push((value - mean) / sd); + } + return row; +} diff --git a/src/lib/analytics/mood-prognosis/ridge.ts b/src/lib/analytics/mood-prognosis/ridge.ts new file mode 100644 index 000000000..8f5335a4f --- /dev/null +++ b/src/lib/analytics/mood-prognosis/ridge.ts @@ -0,0 +1,173 @@ +/** + * Ridge regression, closed form, in about sixty lines of arithmetic. + * + * `(XᵀX + λI)β = Xᵀy`, solved by Gaussian elimination with partial pivoting. + * That is the whole model. It is here rather than in a dependency because a + * dozen standardised columns is a 12×12 system, and adding a package to solve + * one of those would be adding a supply chain to avoid writing a loop. + * + * Two properties the rest of the feature relies on: + * + * * **The intercept is not penalised, because there is no intercept in the + * system.** The target is centred before the solve and the mean is added + * back afterwards, which is the standard treatment: penalising the + * intercept would shrink every prediction towards zero rather than + * towards the person's own average day, and towards zero on a 0-10 + * pleasantness scale means "towards the worst day imaginable". + * * **λ is never zero.** With more columns than the ridge penalty can + * afford, an unpenalised solve is a memoriser: it fits the training days + * perfectly and says nothing about any other day. The grid the validation + * searches starts above zero for that reason. + * + * This is arithmetic, not an AI surface. No provider, no consent gate, no + * egress — see the note at the top of the refresh job. + */ + +/** A fitted model: one coefficient per standardised column, plus the mean. */ +export interface RidgeFit { + /** One per column of the matrix it was fitted on, in the same order. */ + coefficients: number[]; + /** The mean of the training targets, added back on every prediction. */ + intercept: number; + /** + * The mean of each training column, subtracted before every prediction. + * + * Usually zero to floating point, because the matrix arrives z-scored. It is + * carried anyway rather than assumed: centring the target while leaving the + * columns uncentred biases every coefficient, and the bias is small enough + * to look like rounding. Somebody handing this function a raw matrix should + * get the right answer, not a plausible one. + */ + columnMeans: number[]; + /** The penalty this fit used. */ + lambda: number; +} + +/** + * Solve `A x = b` by Gaussian elimination with partial pivoting. + * + * `null` when the system is singular to working precision. The caller treats + * that as "no fit" rather than as a fit of zeros: a singular system means the + * columns are telling the same story twice, and a zero vector would silently + * become a model that predicts the average and calls it a pattern. + */ +export function solveLinearSystem( + matrix: readonly number[][], + vector: readonly number[], +): number[] | null { + const n = vector.length; + if (matrix.length !== n) return null; + // Copy: the caller's XᵀX is reused across the λ grid and elimination is + // destructive. + const a = matrix.map((row, i) => [...row, vector[i]]); + + for (let column = 0; column < n; column++) { + let pivot = column; + for (let row = column + 1; row < n; row++) { + if (Math.abs(a[row][column]) > Math.abs(a[pivot][column])) pivot = row; + } + if (Math.abs(a[pivot][column]) < 1e-10) return null; + if (pivot !== column) { + const swap = a[pivot]; + a[pivot] = a[column]; + a[column] = swap; + } + const pivotValue = a[column][column]; + for (let row = column + 1; row < n; row++) { + const factor = a[row][column] / pivotValue; + if (factor === 0) continue; + for (let k = column; k <= n; k++) a[row][k] -= factor * a[column][k]; + } + } + + const solution = new Array(n).fill(0); + for (let row = n - 1; row >= 0; row--) { + let sum = a[row][n]; + for (let k = row + 1; k < n; k++) sum -= a[row][k] * solution[k]; + solution[row] = sum / a[row][row]; + } + return solution.every((v) => Number.isFinite(v)) ? solution : null; +} + +/** + * Fit `rows` against `targets` at one penalty. + * + * `rows` are already standardised per user (see `features.ts`), so the columns + * are comparable and one λ is a defensible penalty across all of them — + * which is not true of raw minutes beside a 0-10 rating. + */ +export function fitRidge( + rows: readonly (readonly number[])[], + targets: readonly number[], + lambda: number, +): RidgeFit | null { + const n = rows.length; + if (n === 0 || targets.length !== n) return null; + const columns = rows[0].length; + if (columns === 0) return null; + if (rows.some((row) => row.length !== columns)) return null; + + const intercept = targets.reduce((sum, v) => sum + v, 0) / n; + const centredTargets = targets.map((v) => v - intercept); + const columnMeans = Array.from({ length: columns }, (_, j) => { + let sum = 0; + for (let i = 0; i < n; i++) sum += rows[i][j]; + return sum / n; + }); + const centredRows = rows.map((row) => row.map((v, j) => v - columnMeans[j])); + + const xtx: number[][] = Array.from({ length: columns }, () => + new Array(columns).fill(0), + ); + const xty = new Array(columns).fill(0); + for (let i = 0; i < n; i++) { + const row = centredRows[i]; + for (let j = 0; j < columns; j++) { + xty[j] += row[j] * centredTargets[i]; + for (let k = j; k < columns; k++) { + const product = row[j] * row[k]; + xtx[j][k] += product; + if (k !== j) xtx[k][j] += product; + } + } + } + for (let j = 0; j < columns; j++) xtx[j][j] += lambda; + + const coefficients = solveLinearSystem(xtx, xty); + if (coefficients === null) return null; + return { coefficients, intercept, columnMeans, lambda }; +} + +/** Score one standardised row. */ +export function predictWithFit( + fit: RidgeFit, + row: readonly number[], +): number | null { + if (row.length !== fit.coefficients.length) return null; + let sum = fit.intercept; + for (let i = 0; i < row.length; i++) { + sum += fit.coefficients[i] * (row[i] - fit.columnMeans[i]); + } + return Number.isFinite(sum) ? sum : null; +} + +/** + * The per-feature contributions of one scored day. + * + * `coefficient × the day's standardised value`, which is what the stored + * `features` column holds and what the explanation line is generated from. + * Signed, so a feature that pulled the expected value down reads as having + * pulled it down, and the parts sum to the distance between the prediction and + * the person's own average day. + */ +export function featureContributions( + fit: RidgeFit, + featureKeys: readonly string[], + row: readonly number[], +): Array<{ feature: string; contribution: number }> { + return featureKeys.map((feature, index) => ({ + feature, + contribution: + fit.coefficients[index] * (row[index] - fit.columnMeans[index]), + })); +} diff --git a/src/lib/analytics/mood-prognosis/validate.ts b/src/lib/analytics/mood-prognosis/validate.ts new file mode 100644 index 000000000..d5568c2e4 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/validate.ts @@ -0,0 +1,202 @@ +/** + * Choosing the penalty, and measuring how wrong the model is, without letting + * it see the future. + * + * **Rolling origin, expanding window. Never a random k-fold.** Days are not + * exchangeable: a Tuesday sits between a Monday and a Wednesday that resemble + * it, and a shuffled split puts Wednesday in the training set and asks the + * model about Tuesday. The result is a model that looks excellent and is + * useless, and nothing about the number it reports says so. So the folds are + * ranges of a time-ordered array, the training range always ends where the + * validation range starts, and `rolling-origin.test.ts` asserts that on the + * fold indices and on the day keys themselves rather than on the resulting + * error — an error-based assertion would pass a leaking split whenever the + * leak happened to be small. + * + * **The band comes from the validation residuals**, not from a textbook + * standard error on the training fit. A training-fit interval on a model with + * twelve columns and thirty rows is narrow by construction and says nothing; + * the spread of the errors the model actually made on days it had not seen is + * the honest width. It widens when the model is bad, which is the property the + * surface depends on to report low confidence instead of a confident wrong + * number. + */ +import { fitRidge, predictWithFit, type RidgeFit } from "./ridge"; +import type { FeatureMatrix } from "./features"; + +/** + * The stored model identity. Bump it whenever the feature set, the screening + * or the fit changes, so a re-fit is legible in the rows rather than silently + * changing what they mean. Rows carrying an older version stay readable; the + * surface skips a feature key the current build no longer knows. + */ +export const MODEL_VERSION = "mood-ridge-1"; + +/** + * The penalties the validation searches. + * + * Never zero: with a dozen standardised columns over a few dozen days, an + * unpenalised solve memorises the training days and describes no other day. + * The grid is coarse on purpose — the difference between λ=8 and λ=10 is far + * below the noise in thirty days of self-ratings, and a fine grid would only + * be a slower way to overfit the validation set. + */ +export const LAMBDA_GRID = [0.5, 1, 2, 5, 10, 25, 50] as const; + +/** How many validation blocks the window is cut into. */ +export const VALIDATION_FOLDS = 4; + +/** + * The band is the central 80% of the validation residuals: the 10th and the + * 90th percentile. A range wide enough to be honest and narrow enough to be + * worth reading — and empirical, so an asymmetric error stays asymmetric + * instead of being averaged into a symmetric claim. + */ +export const BAND_LOWER_QUANTILE = 0.1; +export const BAND_UPPER_QUANTILE = 0.9; + +/** Fewest rows a fold split is attempted on at all. */ +export const MIN_VALIDATION_ROWS = 10; + +/** One expanding-window split. Every index is into the time-ordered matrix. */ +export interface RollingOriginFold { + /** Training rows are `[0, trainEnd)`. */ + trainEnd: number; + /** Validation rows are `[validationStart, validationEnd)`. */ + validationStart: number; + validationEnd: number; +} + +/** + * Cut a time-ordered window into expanding-window folds. + * + * `trainEnd === validationStart` in every fold, which is the whole invariant: + * the training range is a prefix and the validation range starts exactly where + * it ends. No gap (a gap would waste days), no overlap (an overlap is the + * leak). + */ +export function rollingOriginFolds( + rowCount: number, + folds: number = VALIDATION_FOLDS, +): RollingOriginFold[] { + if (rowCount < MIN_VALIDATION_ROWS || folds < 1) return []; + const block = Math.floor(rowCount / (folds + 1)); + if (block < 1) return []; + + const out: RollingOriginFold[] = []; + for (let index = 1; index <= folds; index++) { + const validationStart = block * index; + const validationEnd = + index === folds ? rowCount : Math.min(rowCount, block * (index + 1)); + if (validationEnd <= validationStart) continue; + out.push({ trainEnd: validationStart, validationStart, validationEnd }); + } + return out; +} + +/** Linear-interpolated quantile of an unsorted sample. */ +export function quantile(values: readonly number[], q: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + if (sorted.length === 1) return sorted[0]; + const position = (sorted.length - 1) * Math.min(1, Math.max(0, q)); + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return sorted[lower]; + return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); +} + +/** What one penalty scored across every fold. */ +export interface LambdaScore { + lambda: number; + /** Residuals (`actual − predicted`) from validation rows only. */ + residuals: number[]; + meanAbsoluteError: number; +} + +/** + * Score one penalty over the folds. Training folds never contain a row dated + * after their validation fold, by construction of {@link rollingOriginFolds}. + */ +export function scoreLambda( + matrix: FeatureMatrix, + lambda: number, + folds: readonly RollingOriginFold[], +): LambdaScore | null { + const residuals: number[] = []; + for (const fold of folds) { + const fit = fitRidge( + matrix.rows.slice(0, fold.trainEnd), + matrix.targets.slice(0, fold.trainEnd), + lambda, + ); + if (fit === null) continue; + for (let i = fold.validationStart; i < fold.validationEnd; i++) { + const predicted = predictWithFit(fit, matrix.rows[i]); + if (predicted === null) continue; + residuals.push(matrix.targets[i] - predicted); + } + } + if (residuals.length === 0) return null; + return { + lambda, + residuals, + meanAbsoluteError: + residuals.reduce((sum, r) => sum + Math.abs(r), 0) / residuals.length, + }; +} + +/** A model, ready to score a day, with the band its own errors earned it. */ +export interface PrognosisModel { + fit: RidgeFit; + features: string[]; + standardisation: FeatureMatrix["standardisation"]; + /** Rows the final fit used. This is the `n` every surface must show. */ + n: number; + /** Offsets from a prediction to the edges of the band, in target units. */ + bandLow: number; + bandHigh: number; + /** Mean absolute error over the validation folds, for the record. */ + meanAbsoluteError: number; + modelVersion: string; +} + +/** + * Validate, choose λ, refit on the whole window, and take the band from the + * validation residuals. + * + * `null` is a refusal and is a normal outcome: too few complete days to split, + * a singular system, or a fold set that produced no residual at all. The + * caller writes no forecast rather than a forecast nobody measured. + */ +export function fitPrognosisModel( + matrix: FeatureMatrix, + lambdaGrid: readonly number[] = LAMBDA_GRID, +): PrognosisModel | null { + const folds = rollingOriginFolds(matrix.rows.length); + if (folds.length === 0) return null; + + let best: LambdaScore | null = null; + for (const lambda of lambdaGrid) { + const score = scoreLambda(matrix, lambda, folds); + if (score === null) continue; + if (best === null || score.meanAbsoluteError < best.meanAbsoluteError) { + best = score; + } + } + if (best === null) return null; + + const fit = fitRidge(matrix.rows, matrix.targets, best.lambda); + if (fit === null) return null; + + return { + fit, + features: matrix.features, + standardisation: matrix.standardisation, + n: matrix.rows.length, + bandLow: quantile(best.residuals, BAND_LOWER_QUANTILE), + bandHigh: quantile(best.residuals, BAND_UPPER_QUANTILE), + meanAbsoluteError: best.meanAbsoluteError, + modelVersion: MODEL_VERSION, + }; +} diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts index f7c858c41..318dcc6cd 100644 --- a/src/lib/mood/linked-context.ts +++ b/src/lib/mood/linked-context.ts @@ -99,6 +99,20 @@ export interface LinkedBody { episodeId: string | null; } +/** + * The five linked figures of one day as plain numbers, for callers that need + * a window of days rather than one day's blocks — the prognosis fit is the + * only one so far. `null` is absence, and a module switched off makes its own + * figures absent rather than zero. + */ +export interface LinkedDayFigures { + sleepAsleep: number | null; + steps: number | null; + activeEnergy: number | null; + restingHeartRate: number | null; + heartRateVariability: number | null; +} + export interface LinkedDayContext { /** The entry's local day, resolved through the entry's own `tz`. */ day: string; @@ -396,6 +410,17 @@ export async function resolveLinkedDayContext( } : MODULE_OFF; + const bodyBlock = await resolveBody(userId, day, enabled); + + return { day, sleep, activity, vitals, body: bodyBlock }; +} + +/** The illness day-log block, split out so the day loop above reads straight. */ +async function resolveBody( + userId: string, + day: string, + enabled: ReadonlyMap, +): Promise> { let body: LinkedBlock = MODULE_OFF; if (enabled.get("illness") ?? true) { // Read only. The illness module owns symptom capture and its severity @@ -419,6 +444,152 @@ export async function resolveLinkedDayContext( episodeId: dayLog?.episodeId ?? null, }; } + return body; +} - return { day, sleep, activity, vitals, body }; +/** + * The five linked figures of one day, as plain numbers, for a whole window. + * + * The same figures the block above answers with, resolved for many days in + * one query instead of one query per day. It exists because the prognosis fits + * over a year of days and the per-day resolver would have run several hundred + * round trips to build one matrix. + * + * It is the SAME engine, deliberately: the module gates come from + * `moduleForMeasurementType` through `typeAvailable`, the cross-source de-dup + * runs through `canonicalRowsOfDay`, the night reconstruction is the one in + * `sleep-night.ts`, and the day totals are the same `sumOfDay` / `latestOfDay` + * the single-day path uses. A second implementation of "what did this day look + * like" would be a second answer, and the mood sheet and the model would start + * disagreeing about the same Tuesday. + * + * A switched-off module answers `null` for its metrics, not zero and not a + * value the owner declined to surface: the model must not learn from a module + * whose figures the account asked not to see. + */ +export async function resolveLinkedDayFigures( + userId: string, + days: readonly string[], + tz: string, +): Promise> { + const out = new Map(); + if (days.length === 0) return out; + + const sorted = [...days].sort(); + const { from } = localDayWindow(sorted[0]); + const { to } = localDayWindow(sorted[sorted.length - 1]); + + const gateStates = await Promise.all( + LINKED_MODULE_KEYS.map( + async (key) => [key, await isModuleEnabled(userId, key)] as const, + ), + ); + const enabled = new Map(gateStates); + const priority = await loadUserSourcePriority(userId); + + const measurements: LinkedRow[] = await prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: { in: [...LINKED_MEASUREMENT_TYPES] }, + measuredAt: { gte: from, lte: to }, + }, + select: { + type: true, + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + // The canonical-source picker keeps insertion order inside a bucket, so a + // stable read order is what makes its device-type tie-break reproducible + // rather than whatever the planner returned this time. + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + }); + + // One pass to bucket by local day, rather than filtering the whole window + // once per day: over a year of history that difference is the whole cost of + // the fit's data preparation. + const byDay = new Map(); + for (const row of measurements) { + const key = moodDateKey(row.measuredAt, tz); + const bucket = byDay.get(key); + if (bucket) bucket.push(row); + else byDay.set(key, [row]); + } + + const sleepAvailable = typeAvailable("SLEEP_DURATION", enabled); + const activityAvailable = typeAvailable("ACTIVITY_STEPS", enabled); + const vitalsAvailable = typeAvailable("RESTING_HEART_RATE", enabled); + + const nightByDay = new Map(); + const inBedByDay = new Map(); + if (sleepAvailable) { + const stageRows = measurements.filter((r) => r.type === "SLEEP_DURATION"); + if (stageRows.length > 0) { + for (const night of reconstructSleepNights( + stageRows as unknown as SleepStageRow[], + tz, + priority, + )) { + nightByDay.set(night.night, night.asleepMinutes ?? null); + inBedByDay.set(night.night, night.inBedMinutes ?? null); + } + } + } + + for (const day of days) { + const dayRows = byDay.get(day) ?? []; + out.set(day, { + sleepAsleep: sleepAvailable ? (nightByDay.get(day) ?? null) : null, + steps: activityAvailable + ? valueOf( + sumOfDay(dayRows, "ACTIVITY_STEPS", "steps", day, tz, priority), + ) + : null, + activeEnergy: activityAvailable + ? valueOf( + sumOfDay( + dayRows, + "ACTIVE_ENERGY_BURNED", + "kcal", + day, + tz, + priority, + ), + ) + : null, + restingHeartRate: vitalsAvailable + ? valueOf( + latestOfDay( + dayRows, + "RESTING_HEART_RATE", + "bpm", + day, + tz, + priority, + ), + ) + : null, + heartRateVariability: vitalsAvailable + ? valueOf( + latestOfDay( + dayRows, + "HEART_RATE_VARIABILITY", + "ms", + day, + tz, + priority, + ), + ) + : null, + }); + } + return out; +} + +/** A figure as a plain number, with absence staying absence. */ +function valueOf(figure: LinkedFigure): number | null { + return figure.present ? figure.value : null; } From 244e8ffbc73733e4768f387fe45a0b2e8f7562e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 11:31:39 +0200 Subject: [PATCH 04/23] feat(mood): rebuild the forecast rows nightly, and clear them when they stop being true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job, never a route. A forecast written as a side effect of opening a page is a function of browsing rather than of data, and the prognosis also has to be a value the person cannot overwrite — so one file writes the table and a guard holds it there. The cohort is the union of two sets and the second one carries the weight: accounts with enough recent rated days, plus every account that already holds rows. Without the second, an account that deleted its entries would fall out of the sweep and keep last month's answer under a fresh-looking date, with nothing on the surface to say so. A pass replaces rather than accumulates. Every account it touches ends holding exactly the rows that run wrote, or none: below the threshold, module off, no pattern in the data, no recent day carrying everything the fit needs — every one of those clears what was there. Per-account failures are counted and the pass continues; only an all-accounts failure fails the job. Runs at 04:35, after the inputs have settled and clear of the geo backfill on the hour's fortieth minute. The arithmetic is a ridge regression and nothing else: no provider, no key, no egress, no consent gate, and the module says so at the top so nobody wires one that does not apply or assumes one that is not there. --- src/lib/analytics/mood-prognosis/forecast.ts | 148 +++++++++++ src/lib/analytics/mood-prognosis/load.ts | 190 ++++++++++++++ .../__tests__/mood-prognosis-refresh.test.ts | 152 +++++++++++ src/lib/jobs/job-outcome.ts | 3 + src/lib/jobs/mood-prognosis-refresh.ts | 241 ++++++++++++++++++ src/lib/jobs/reminder/register-maintenance.ts | 22 ++ src/lib/jobs/subsystem-surface.ts | 1 + 7 files changed, 757 insertions(+) create mode 100644 src/lib/analytics/mood-prognosis/forecast.ts create mode 100644 src/lib/analytics/mood-prognosis/load.ts create mode 100644 src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts create mode 100644 src/lib/jobs/mood-prognosis-refresh.ts diff --git a/src/lib/analytics/mood-prognosis/forecast.ts b/src/lib/analytics/mood-prognosis/forecast.ts new file mode 100644 index 000000000..e5cc768b1 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/forecast.ts @@ -0,0 +1,148 @@ +/** + * One account's answer, computed and handed back as rows to write. + * + * Pure: it takes the days somebody logged and returns either the rows a pass + * should store or a refusal with the reason. It does not touch the database, + * and that is a boundary rather than a style — `MoodPrediction` has exactly + * one writer, the refresh job, and a guard holds it there + * (`src/__tests__/mood-prediction-write-surface-guard.test.ts`). Keeping the + * arithmetic here and the write there is what lets the whole decision path be + * tested without a container. + * + * **It refuses more often than it answers, and that is the design.** Too few + * days, no feature that clears the screening, no recent day carrying every + * feature the fit needs, a singular system — every one of them returns a + * refusal and the account is left with no forecast at all. The surface then + * says there is not enough to go on, which is true, instead of showing a + * number built out of three days and a coincidence. + */ +import { + buildFeatureMatrix, + standardiseDay, + type PrognosisDayInput, +} from "./features"; +import { PROGNOSIS_WRITE_WINDOW_DAYS } from "./load"; +import { featureContributions, predictWithFit } from "./ridge"; +import { forecastGate } from "./thresholds"; +import { fitPrognosisModel } from "./validate"; + +/** The lowest and highest value the target scale can express. */ +export const TARGET_MIN = 0; +export const TARGET_MAX = 10; + +/** One stored contribution: what a feature moved the expected value by. */ +export interface FeatureContribution { + feature: string; + contribution: number; +} + +/** One day's forecast, in the shape the row is written in. */ +export interface ForecastRow { + date: string; + predicted: number; + ciLow: number; + ciHigh: number; + n: number; + modelVersion: string; + /** Ordered by absolute contribution, largest first. */ + contributions: FeatureContribution[]; +} + +/** Why an account gets no forecast. Every one of these is a normal outcome. */ +export type ForecastRefusal = + /** Below the ladder's forecast threshold. */ + | "too-few-days" + /** Nothing recorded often enough, or varied enough, to screen in. */ + | "no-pattern" + /** A pattern, but no recent day carrying every feature it needs. */ + | "no-scorable-day"; + +export type ForecastResult = + | { + status: "written"; + rows: ForecastRow[]; + /** Complete days the fit used. The `n` every surface must show. */ + n: number; + modelVersion: string; + /** How wide the band came out, for the job's event only. */ + bandWidth: number; + featureCount: number; + } + | { status: "refused"; reason: ForecastRefusal }; + +function clampToScale(value: number): number { + return Math.min(TARGET_MAX, Math.max(TARGET_MIN, value)); +} + +function round(value: number, digits: number): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +/** + * Fit the account's window and score its most recent days. + * + * The ladder is read against the number of DAYS carrying a self-rating, which + * is the number a person can see themselves growing. The `n` stored on each + * row is the number of complete days the fit actually used, which can be + * smaller — a day that is missing one of the fit's inputs cannot be part of + * it. Both numbers are honest and they answer different questions; the one on + * the surface beside the value is `n`, because that is what the value rests on. + */ +export function computeForecast( + days: readonly PrognosisDayInput[], + writeWindowDays: number = PROGNOSIS_WRITE_WINDOW_DAYS, +): ForecastResult { + const rated = days.filter((d) => typeof d.a1 === "number"); + const gate = forecastGate(rated.length); + if (!gate.present) return { status: "refused", reason: "too-few-days" }; + + const matrix = buildFeatureMatrix(days); + const model = matrix === null ? null : fitPrognosisModel(matrix); + if (model === null) return { status: "refused", reason: "no-pattern" }; + + const rows: ForecastRow[] = []; + for (const day of rated.slice(-writeWindowDays)) { + const standardised = standardiseDay(day, model.standardisation); + // A day missing one of the fit's features cannot be scored, and nothing is + // filled in for it. It simply has no forecast. + if (standardised === null) continue; + const raw = predictWithFit(model.fit, standardised); + if (raw === null) continue; + + rows.push({ + date: day.day, + predicted: round(clampToScale(raw), 2), + // The band travels with the prediction and is clamped to the same scale: + // a lower edge below zero would be a claim the scale cannot express. + ciLow: round(clampToScale(raw + model.bandLow), 2), + ciHigh: round(clampToScale(raw + model.bandHigh), 2), + n: model.n, + modelVersion: model.modelVersion, + contributions: featureContributions( + model.fit, + model.features, + standardised, + ) + .filter((c) => Number.isFinite(c.contribution)) + .sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution)) + .map((c) => ({ + feature: c.feature, + contribution: round(c.contribution, 3), + })), + }); + } + + if (rows.length === 0) { + return { status: "refused", reason: "no-scorable-day" }; + } + + return { + status: "written", + rows, + n: model.n, + modelVersion: model.modelVersion, + bandWidth: round(model.bandHigh - model.bandLow, 2), + featureCount: model.features.length, + }; +} diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts new file mode 100644 index 000000000..eb2ba1b99 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/load.ts @@ -0,0 +1,190 @@ +/** + * The database read behind the fit, and nothing else. + * + * Separate from `features.ts` on purpose: that module is pure and is imported + * by the surface that labels a stored feature key, and a Prisma import in it + * would pull the database client towards a client bundle. The split is the + * boundary, not a preference. + * + * **One row per day, taken whole from one entry.** A day with two entries + * contributes the later one — its target, its dimensions and its context + * together. Averaging the ratings and taking the context from whichever entry + * happened to carry one would build a row nobody logged, and the model would + * then be fitted on days that did not happen. + * + * The day key is the entry's own `date`, already resolved in the zone the + * entry was written in. Re-deriving it here from `moodLoggedAt` would be a + * second opinion about which day a row belongs to, which is the class of bug + * the per-row `tz` column was added to end. + */ +import { prisma } from "@/lib/db"; +import { decodeKeyList } from "@/lib/mood/context-vocabulary"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { resolveLinkedDayFigures } from "@/lib/mood/linked-context"; + +import type { PrognosisDayInput } from "./features"; + +/** + * How far back the fit looks. + * + * A year: long enough to hold a seasonal swing, short enough that a life that + * changed two years ago is not still being fitted against today. Longer would + * also make the per-user standardisation describe somebody the person no + * longer is. + */ +export const PROGNOSIS_FIT_WINDOW_DAYS = 365; + +/** + * How many recent days the job writes a forecast for. + * + * Not the whole window: a forecast is worth storing for the days somebody + * might still be looking at. Older rows are recomputable and are deleted on + * every pass, which is also what keeps a stale forecast from outliving the + * data it was fitted on. + */ +export const PROGNOSIS_WRITE_WINDOW_DAYS = 7; + +/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */ +export function dayKeyBefore(now: Date, days: number): string { + return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10); +} + +/** + * Every day of the fit window, in day order, with everything the matrix needs. + * + * The linked figures come from the resolver that owns them, over the whole + * window in one query. They are read in the account's display zone: the entry + * rows carry their own zone for their own day key, but a step count has to be + * bucketed under some zone, and the account's own is the one every other + * surface uses. + */ +export async function loadPrognosisDays( + userId: string, + now: Date, + timezone: string | null, + windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS, +): Promise { + const since = dayKeyBefore(now, windowDays); + const entries = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, date: { gte: since } }, + // Ascending, so a later entry on the same day overwrites the earlier one + // in the fold below and the "last entry of the day wins" rule is the + // read order rather than a comparison. + orderBy: [{ date: "asc" }, { moodLoggedAt: "asc" }], + select: { + date: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + context: { + select: { + workStatus: true, + workMinutes: true, + overtimeMinutes: true, + workLoad: true, + workSatisfaction: true, + contactCircles: true, + contactForm: true, + contactExtent: true, + contactQuality: true, + contactSupport: true, + leisureCategories: true, + leisureMinutes: true, + leisureJoy: true, + leisureRecovery: true, + eventType: true, + eventValence: true, + // The note is deliberately not selected. It is encrypted free text + // about somebody's day and the model has no use for it; a fit does + // not read prose, and a query that decrypted it anyway would be + // reading a diary to compute an average. + }, + }, + }, + }); + + const byDay = new Map(); + for (const entry of entries) { + byDay.set(entry.date, { + day: entry.date, + a1: entry.moodA1, + dimensions: { + a2: entry.stressA2, + a3: entry.energyA3, + a4: entry.connectionA4, + a5: entry.stabilityA5, + }, + context: entry.context + ? { + workStatus: entry.context.workStatus, + workMinutes: entry.context.workMinutes, + overtimeMinutes: entry.context.overtimeMinutes, + workLoad: entry.context.workLoad, + workSatisfaction: entry.context.workSatisfaction, + contactCircles: decodeKeyList(entry.context.contactCircles), + contactForm: entry.context.contactForm, + contactExtent: entry.context.contactExtent, + contactQuality: entry.context.contactQuality, + contactSupport: entry.context.contactSupport, + leisureCategories: decodeKeyList(entry.context.leisureCategories), + leisureMinutes: entry.context.leisureMinutes, + leisureJoy: entry.context.leisureJoy, + leisureRecovery: entry.context.leisureRecovery, + eventType: entry.context.eventType, + eventValence: entry.context.eventValence, + } + : null, + linked: { + sleepAsleep: null, + steps: null, + activeEnergy: null, + restingHeartRate: null, + heartRateVariability: null, + }, + }); + } + + const days = [...byDay.keys()].sort(); + if (days.length === 0) return []; + + const linked = await resolveLinkedDayFigures( + userId, + days, + timezone ?? DEFAULT_TIMEZONE, + ); + for (const day of days) { + const figures = linked.get(day); + if (figures) byDay.get(day)!.linked = figures; + } + + return days.map((day) => byDay.get(day)!); +} + +/** + * How many days of the window carry a self-rated pleasantness. + * + * The number the ladder is read against, and the number a surface quotes when + * it explains why there is nothing yet. Days, not entries: two entries on one + * Tuesday are one Tuesday, and counting them as two would let somebody reach + * a threshold by logging twice a day for a fortnight. + */ +export async function countQualifyingDays( + userId: string, + now: Date, + windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS, +): Promise { + const since = dayKeyBefore(now, windowDays); + const days = await prisma.moodEntry.findMany({ + where: { + userId, + deletedAt: null, + moodA1: { not: null }, + date: { gte: since }, + }, + distinct: ["date"], + select: { date: true }, + }); + return days.length; +} diff --git a/src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts b/src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts new file mode 100644 index 000000000..b3c77861d --- /dev/null +++ b/src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts @@ -0,0 +1,152 @@ +/** + * The nightly mood-forecast rebuild. + * + * Three properties, each of which fails silently if it breaks: + * + * 1. **The sweep visits accounts that hold rows, not only accounts that + * qualify.** An account that deleted its entries drops out of the + * eligibility half of the cohort, and if that were the whole cohort it + * would keep last month's forecast forever, under whatever date the last + * pass stamped on it. Nothing on the surface would say so. + * 2. **A refusal clears.** Below the threshold, module off, no pattern — all + * three leave the account with no rows rather than with stale ones. + * 3. **One account's bad data costs one account.** The pass counts the + * failure and carries on; only an all-accounts failure fails the job. + * + * The cohort query and the module gate are mocked, which is what makes the + * decision path testable without a container. The arithmetic underneath has + * its own tests, and the whole thing is exercised end to end against real + * Postgres in `tests/integration/mood-prognosis-ladder.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/jobs/reminder/shared", () => ({ + getWorkerPrisma: () => ({ + moodEntry: { groupBy: groupByMock }, + moodPrediction: { + findMany: predictionFindManyMock, + deleteMany: deleteManyMock, + }, + }), +})); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: async ( + _name: string, + fn: (evt: { addMeta: () => void }) => Promise, + ) => fn({ addMeta: () => {} }), +})); + +const groupByMock = vi.fn(); +const predictionFindManyMock = vi.fn(); +const deleteManyMock = vi.fn(); + +import { + MOOD_PROGNOSIS_REFRESH_CRON, + MOOD_PROGNOSIS_REFRESH_QUEUE, + handleMoodPrognosisRefresh, + runMoodPrognosisRefresh, +} from "@/lib/jobs/mood-prognosis-refresh"; +import { MIN_ENTRIES_FOR_FORECAST } from "@/lib/analytics/mood-prognosis/thresholds"; + +const NOW = new Date("2026-08-08T02:35:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + groupByMock.mockResolvedValue([ + { userId: "user-1", _count: { _all: MIN_ENTRIES_FOR_FORECAST } }, + { userId: "user-2", _count: { _all: 120 } }, + ]); + predictionFindManyMock.mockResolvedValue([]); + deleteManyMock.mockResolvedValue({ count: 0 }); +}); + +describe("mood-prognosis-refresh", () => { + it("runs at 04:35, in the slot the neighbouring jobs leave free", () => { + expect(MOOD_PROGNOSIS_REFRESH_CRON).toBe("35 4 * * *"); + expect(MOOD_PROGNOSIS_REFRESH_QUEUE).toBe("mood-prognosis-refresh"); + }); + + it("refreshes every eligible account", async () => { + const refresh = vi.fn(async () => ({ rows: 7, refusal: null })); + const summary = await runMoodPrognosisRefresh(NOW, { refresh }); + + expect(summary.scanned).toBe(2); + // The count is the point: a cohort query that matched nothing would + // satisfy "no bad rows" exactly as well as a working sweep. + expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledWith("user-1", NOW); + expect(summary.refreshed).toBe(2); + expect(summary.rowsWritten).toBe(14); + }); + + it("leaves an account below the entry cut out of the cohort", async () => { + groupByMock.mockResolvedValue([ + { userId: "user-1", _count: { _all: MIN_ENTRIES_FOR_FORECAST - 1 } }, + ]); + const refresh = vi.fn(async () => ({ rows: 1, refusal: null })); + const summary = await runMoodPrognosisRefresh(NOW, { refresh }); + + expect(summary.scanned).toBe(0); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("still visits an account that holds rows but no longer qualifies", async () => { + groupByMock.mockResolvedValue([]); + predictionFindManyMock.mockResolvedValue([{ userId: "user-stale" }]); + const refresh = vi.fn(async () => ({ + rows: 0, + refusal: "too-few-days" as const, + })); + + const summary = await runMoodPrognosisRefresh(NOW, { refresh }); + + // Without this arm the account would keep its forecast forever: it is not + // in the eligible set any more, so nothing would ever come back for it. + expect(summary.scanned).toBe(1); + expect(refresh).toHaveBeenCalledWith("user-stale", NOW); + expect(summary.refused).toBe(1); + expect(summary.refreshed).toBe(0); + }); + + it("computes nothing and stores nothing while the module is off, and clears what it stored before", async () => { + const refresh = vi.fn(async () => ({ rows: 7, refusal: null })); + const summary = await runMoodPrognosisRefresh(NOW, { + isAvailable: async () => false, + refresh, + }); + + expect(refresh).not.toHaveBeenCalled(); + expect(summary.moduleOff).toBe(2); + expect(summary.rowsWritten).toBe(0); + expect(deleteManyMock).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + }); + expect(deleteManyMock).toHaveBeenCalledTimes(2); + }); + + it("counts an account whose pass throws and carries on", async () => { + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("bad rows")) + .mockResolvedValueOnce({ rows: 3, refusal: null }); + + const summary = await runMoodPrognosisRefresh(NOW, { refresh }); + + expect(summary.failed).toBe(1); + expect(summary.refreshed).toBe(1); + expect(summary.rowsWritten).toBe(3); + }); + + it("fails the job only when every account failed", async () => { + const outcome = await handleMoodPrognosisRefresh([]); + // The default refresh path reaches the real loader, which the mocked + // client cannot serve, so every account throws — which is exactly the + // all-accounts case. + expect(outcome.ok).toBe(false); + }); +}); diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 2c84daf4b..2c255d692 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -121,6 +121,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "medications_evaluated", "metric_assessments_warmed", "missed_doses_minted", + "module_off", "mood_entries_migrated", "mood_pruned", "notifications_dispatched", @@ -147,8 +148,10 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "rearmed", "records_read", "refreshed", + "refused", "reminders_due", "removed", + "rows_written", "restore_drill_age_days", "restore_drill_ciphertext_bytes", "restore_drill_intake_events", diff --git a/src/lib/jobs/mood-prognosis-refresh.ts b/src/lib/jobs/mood-prognosis-refresh.ts new file mode 100644 index 000000000..30ab95fba --- /dev/null +++ b/src/lib/jobs/mood-prognosis-refresh.ts @@ -0,0 +1,241 @@ +/** + * Nightly rebuild of the mood forecast rows. + * + * **This is not an AI surface.** A ridge regression over a dozen standardised + * columns is arithmetic: no provider, no key, no egress, no consent gate. It + * is stated here because the shape of the feature invites the assumption in + * both directions — nobody should wire a consent check that does not apply, + * and nobody should assume one exists where none is needed. If a later change + * narrates this forecast through a language model, THAT call site inherits + * `ai-consent-enforcement-guard.test.ts` and has to declare its surface. + * + * **This file is the only writer of `MoodPrediction`**, held there by + * `src/__tests__/mood-prediction-write-surface-guard.test.ts`. The reason is + * the product rule: a day carries the person's own rating and the value their + * past days imply, the two are never merged, and the second one is not + * editable. A route that wrote this table would make it editable. The cycle + * module learned the other half of the same lesson the expensive way — a + * forecast written as a side effect of opening a page is a function of + * browsing rather than of data. + * + * **A pass replaces; it never accumulates.** Every account it touches ends the + * pass holding exactly the rows this run wrote, or none. An account that has + * fallen below the threshold, switched the module off, or stopped showing a + * pattern is left with nothing rather than with last week's answer under a + * fresh-looking date. That is the stale rule, chosen over marking rows + * superseded because a derived row is cheap to rebuild and a superseded row is + * one more state every reader has to handle correctly. + * + * Runs at 04:35 Europe/Berlin. The 03:xx block is retention; 04:10 is the + * document purge, 04:20 the cycle forecast, 04:25 the achievement sweep, 04:30 + * the insight pre-generation, and 04:45/50/55 the recovery, stress and strain + * scores. 04:35 is free, and it sits after the inputs this job reads have + * settled. (04:40 is not free: the geo backfill runs at `40 * * * *`.) + */ +import type { Job } from "pg-boss"; + +import { + computeForecast, + type ForecastRefusal, +} from "@/lib/analytics/mood-prognosis/forecast"; +import { + PROGNOSIS_FIT_WINDOW_DAYS, + dayKeyBefore, + loadPrognosisDays, +} from "@/lib/analytics/mood-prognosis/load"; +import { MIN_ENTRIES_FOR_FORECAST } from "@/lib/analytics/mood-prognosis/thresholds"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { isModuleEnabled } from "@/lib/modules/gate"; + +import { getWorkerPrisma } from "./reminder/shared"; + +export const MOOD_PROGNOSIS_REFRESH_QUEUE = "mood-prognosis-refresh"; + +export const MOOD_PROGNOSIS_REFRESH_CRON = "35 4 * * *"; + +export interface MoodPrognosisRefreshPayload { + triggeredAt: string; +} + +export interface MoodPrognosisRefreshSummary { + /** Accounts the cohort query returned, plus any holding stale rows. */ + scanned: number; + /** Accounts whose forecast rows now reflect their data. */ + refreshed: number; + /** Rows written across every account. */ + rowsWritten: number; + /** Accounts the fit refused, whose rows were cleared. */ + refused: number; + /** Accounts whose module is off, whose rows were cleared. */ + moduleOff: number; + /** Accounts whose pass threw. */ + failed: number; +} + +/** + * Rebuild one account's rows, or clear them. + * + * The write and the delete are the two calls the guard allows in this file, + * and they are here rather than beside the arithmetic so the single-writer + * claim is a property of one module rather than a convention across two. + */ +async function refreshAccount( + userId: string, + now: Date, +): Promise<{ rows: number; refusal: ForecastRefusal | null }> { + const prisma = getWorkerPrisma(); + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const days = await loadPrognosisDays(userId, now, user?.timezone ?? null); + const result = computeForecast(days); + + if (result.status === "refused") { + await prisma.moodPrediction.deleteMany({ where: { userId } }); + return { rows: 0, refusal: result.reason }; + } + + const written: string[] = []; + for (const row of result.rows) { + const data = { + predicted: row.predicted, + ciLow: row.ciLow, + ciHigh: row.ciHigh, + n: row.n, + modelVersion: row.modelVersion, + features: JSON.stringify(row.contributions), + computedAt: now, + }; + await prisma.moodPrediction.upsert({ + where: { userId_date: { userId, date: row.date } }, + create: { userId, date: row.date, ...data }, + update: data, + }); + written.push(row.date); + } + // Everything outside this pass goes, so no account holds a row from an older + // fit beside a row from this one. + await prisma.moodPrediction.deleteMany({ + where: { userId, date: { notIn: written } }, + }); + return { rows: written.length, refusal: null }; +} + +/** + * Refresh every eligible account. Exported for the test; the handler below is + * the queue binding. + * + * The cohort is the union of two sets, and the second one is the whole point: + * accounts with enough recent rated days to be worth fitting, AND accounts + * that already hold forecast rows. Without the second, an account that deleted + * its entries would drop out of the cohort and keep its old forecast forever — + * the sweep would never visit it again to clear it. + */ +export async function runMoodPrognosisRefresh( + now: Date = new Date(), + options: { + isAvailable?: (userId: string) => Promise; + refresh?: typeof refreshAccount; + } = {}, +): Promise { + const isAvailable = + options.isAvailable ?? + ((userId: string) => isModuleEnabled(userId, "mood")); + const refresh = options.refresh ?? refreshAccount; + const prisma = getWorkerPrisma(); + + const since = dayKeyBefore(now, PROGNOSIS_FIT_WINDOW_DAYS); + const [eligible, holders] = await Promise.all([ + prisma.moodEntry.groupBy({ + by: ["userId"], + where: { + deletedAt: null, + moodA1: { not: null }, + date: { gte: since }, + }, + _count: { _all: true }, + }), + prisma.moodPrediction.findMany({ + distinct: ["userId"], + select: { userId: true }, + }), + ]); + + const userIds = new Set(); + for (const group of eligible) { + // The cheap cut, so an account with a handful of entries never pays for a + // window read. The real gate is the ladder inside the fit, which counts + // DAYS rather than rows. + if (group._count._all >= MIN_ENTRIES_FOR_FORECAST) + userIds.add(group.userId); + } + for (const holder of holders) userIds.add(holder.userId); + + const summary: MoodPrognosisRefreshSummary = { + scanned: userIds.size, + refreshed: 0, + rowsWritten: 0, + refused: 0, + moduleOff: 0, + failed: 0, + }; + + for (const userId of userIds) { + try { + if (!(await isAvailable(userId))) { + // An operator- or user-disabled module computes nothing and stores + // nothing — and gives back anything it stored before. + await prisma.moodPrediction.deleteMany({ where: { userId } }); + summary.moduleOff += 1; + continue; + } + const outcome = await refresh(userId, now); + if (outcome.refusal !== null) { + summary.refused += 1; + continue; + } + summary.refreshed += 1; + summary.rowsWritten += outcome.rows; + } catch { + // One account's bad data must not cost every other account its forecast. + // The count is what makes a systemic failure visible. + summary.failed += 1; + } + } + + return summary; +} + +export async function handleMoodPrognosisRefresh( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.mood-prognosis-refresh", async (evt) => { + const summary = await runMoodPrognosisRefresh(); + evt.addMeta("mood_prognosis_scanned", summary.scanned); + evt.addMeta("mood_prognosis_refreshed", summary.refreshed); + evt.addMeta("mood_prognosis_rows", summary.rowsWritten); + evt.addMeta("mood_prognosis_refused", summary.refused); + evt.addMeta("mood_prognosis_module_off", summary.moduleOff); + evt.addMeta("mood_prognosis_failed", summary.failed); + + // Every account failing is a systemic fault (a dead pool, a schema drift), + // not one account's bad data. Anything short of that stays a success with + // the count on the event. + if (summary.failed > 0 && summary.failed === summary.scanned) { + return jobFailed( + `mood-prognosis-refresh failed for all ${summary.failed} accounts`, + ); + } + return jobDone({ + users_scanned: summary.scanned, + refreshed: summary.refreshed, + rows_written: summary.rowsWritten, + refused: summary.refused, + module_off: summary.moduleOff, + users_failed: summary.failed, + }); + }); +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 332dd8c1d..6eee62d3b 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -195,6 +195,12 @@ import { handleCyclePredictionRefresh, type CyclePredictionRefreshPayload, } from "@/lib/jobs/cycle-prediction-refresh"; +import { + MOOD_PROGNOSIS_REFRESH_QUEUE, + MOOD_PROGNOSIS_REFRESH_CRON, + handleMoodPrognosisRefresh, + type MoodPrognosisRefreshPayload, +} from "@/lib/jobs/mood-prognosis-refresh"; import { ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, ACHIEVEMENT_UNLOCK_SWEEP_CRON, @@ -365,6 +371,10 @@ const allQueues = [ // scans. Without this entry the schedule silently no-ops and the forecast // is only as fresh as the last time somebody opened the calendar. CYCLE_PREDICTION_REFRESH_QUEUE, + // v1.38 — nightly rebuild of the mood forecast rows. Without this entry the + // schedule silently no-ops: nothing else writes them, so the surface would + // answer "not enough data yet" forever on an account that has plenty. + MOOD_PROGNOSIS_REFRESH_QUEUE, // v1.35.3 — nightly pin for newly unlocked achievements. Without this entry // the schedule silently no-ops and no unlock date is ever made durable. ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, @@ -521,6 +531,10 @@ const schedules: ScheduleEntry[] = [ CYCLE_PREDICTION_REFRESH_CRON, cronIsTheRetry, ], + // v1.38 — daily 04:35 Europe/Berlin rebuild of the mood forecast rows. + // Tomorrow's tick is the retry: the fit moves on a day scale and a night + // late writes the same rows from the same days. + [MOOD_PROGNOSIS_REFRESH_QUEUE, MOOD_PROGNOSIS_REFRESH_CRON, cronIsTheRetry], // v1.35.3 — daily 04:25 Europe/Berlin pin for newly unlocked achievements. // Tomorrow's tick is the retry: the date written is the computed completion // date, so a night late writes the identical row. @@ -743,6 +757,14 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleCyclePredictionRefresh, ); + // v1.38 — nightly mood-forecast rebuild. Single-flight: two ticks would fit + // the same windows twice and the second pass would write the identical rows. + await createAndWork( + boss, + MOOD_PROGNOSIS_REFRESH_QUEUE, + { localConcurrency: 1 }, + handleMoodPrognosisRefresh, + ); // v1.35.3 — nightly achievement-unlock pin. Single-flight: the underlying // createMany is idempotent on the (userId, achievementId) unique, so a // second concurrent tick would only re-walk the same accounts. diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index 04550e269..89dd1f05f 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -117,6 +117,7 @@ const subsystemSurface = { "push-attempt-cleanup": { audience: "system" }, "arrival-reaction-cleanup": { audience: "system" }, "cycle-prediction-refresh": { audience: "system" }, + "mood-prognosis-refresh": { audience: "system" }, "achievement-unlock-sweep": { audience: "system" }, "measurement-tombstone-cleanup": { audience: "system" }, "coach-message-cleanup": { audience: "system" }, From f48afed82db2aa5978b91bb134af68bae4436285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 11:43:58 +0200 Subject: [PATCH 05/23] feat(mood): show the day's own rating beside what the account's days imply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 19 ++ messages/en.json | 19 ++ messages/es.json | 19 ++ messages/fr.json | 19 ++ messages/it.json | 19 ++ messages/pl.json | 19 ++ .../copy-matches-behaviour-guard.test.ts | 154 ++++++++++- src/__tests__/sharing-surface-guard.test.ts | 10 +- src/app/api/mood/prognosis/route.ts | 63 +++++ src/app/insights/mood/page.tsx | 21 ++ .../__tests__/mood-prognosis-card.test.tsx | 164 ++++++++++++ .../insights/mood/mood-prognosis-card.tsx | 241 ++++++++++++++++++ .../insights/mood/prognosis-feature-label.ts | 71 ++++++ src/lib/analytics/mood-prognosis/read.ts | 207 +++++++++++++++ src/lib/query-keys/mood.ts | 12 + 15 files changed, 1053 insertions(+), 4 deletions(-) create mode 100644 src/app/api/mood/prognosis/route.ts create mode 100644 src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx create mode 100644 src/components/insights/mood/mood-prognosis-card.tsx create mode 100644 src/components/insights/mood/prognosis-feature-label.ts create mode 100644 src/lib/analytics/mood-prognosis/read.ts diff --git a/messages/de.json b/messages/de.json index 0b4dc8816..337c3e442 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3785,6 +3785,25 @@ "statement": "An Tagen mit „{label}“ lag die Stimmung im Durchschnitt {direction}: {withAvg} gegenüber {withoutAvg}.", "counts": "{withDays} Tage mit, {withoutDays} Tage ohne", "caveat": "Das zeigt einen Zusammenhang, keine Ursache." + }, + "prognosis": { + "title": "Deine Einschätzung und was deine bisherigen Tage nahelegen", + "selfLabel": "Deine Einschätzung", + "expectedLabel": "Auf Basis deiner Muster erwartet", + "band": "Bereich {low} bis {high}", + "statement": "Auf Basis deiner bisherigen Muster wäre für {date} ein Wert um {value} zu erwarten gewesen. Ermittelt aus {n} Tagen, die alles enthalten, was der Vergleich braucht.", + "provisional": "Noch vorläufig: das Muster stützt sich auf eine kurze Strecke von Tagen.", + "notToday": "Das beschreibt {date}, nicht heute.", + "deviationWithin": "Deine eigene Einschätzung liegt in diesem Bereich.", + "deviationAbove": "Deine eigene Einschätzung liegt {amount} Punkte über diesem Bereich.", + "deviationBelow": "Deine eigene Einschätzung liegt {amount} Punkte unter diesem Bereich.", + "weightedTitle": "Was an diesem Tag am stärksten gewichtet wurde", + "weightedUp": "{label} — zählte in Richtung eines höheren erwarteten Werts", + "weightedDown": "{label} — zählte in Richtung eines niedrigeren erwarteten Werts", + "caveat": "Das ist ein Vergleich mit deinen eigenen bisherigen Tagen, keine Aussage darüber, wie ein Tag zustande gekommen ist.", + "learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.", + "noPattern": "In den bisher erfassten {entries} Tagen ist nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wer den Kontext eines Tages mit erfasst, macht mehr davon vergleichbar.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/en.json b/messages/en.json index f18373297..074d87158 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3785,6 +3785,25 @@ "statement": "On days with “{label}” the mood averaged {direction}: {withAvg} against {withoutAvg}.", "counts": "{withDays} days with, {withoutDays} days without", "caveat": "That shows a connection, not a cause." + }, + "prognosis": { + "title": "Your rating, and what your own days suggest", + "selfLabel": "Your rating", + "expectedLabel": "Expected on your own pattern", + "band": "Range {low} to {high}", + "statement": "On your own patterns so far, a value around {value} would have been expected for {date}. Built from {n} days that carried everything the comparison needs.", + "provisional": "Still provisional: the pattern rests on a short stretch of days.", + "notToday": "This describes {date}, not today.", + "deviationWithin": "Your own rating sits inside that range.", + "deviationAbove": "Your own rating sits {amount} points above that range.", + "deviationBelow": "Your own rating sits {amount} points below that range.", + "weightedTitle": "What weighed most in this day's comparison", + "weightedUp": "{label} — counted towards a higher expected value", + "weightedDown": "{label} — counted towards a lower expected value", + "caveat": "This is a comparison with your own past days, not a statement about how a day came to go the way it did.", + "learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.", + "noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. Recording a day's context makes more of it comparable.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/es.json b/messages/es.json index 7b3227215..0c7e6f6e0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3785,6 +3785,25 @@ "statement": "En los días con «{label}» el ánimo promedió {direction}: {withAvg} frente a {withoutAvg}.", "counts": "{withDays} días con, {withoutDays} días sin", "caveat": "Esto muestra una relación, no una causa." + }, + "prognosis": { + "title": "Tu valoración y lo que sugieren tus días anteriores", + "selfLabel": "Tu valoración", + "expectedLabel": "Esperado según tus propios patrones", + "band": "Rango de {low} a {high}", + "statement": "Según tus patrones hasta ahora, para {date} cabría esperar un valor en torno a {value}. Calculado a partir de {n} días que reúnen todo lo que la comparación necesita.", + "provisional": "Todavía provisional: el patrón se apoya en pocos días.", + "notToday": "Esto describe {date}, no hoy.", + "deviationWithin": "Tu propia valoración está dentro de ese rango.", + "deviationAbove": "Tu propia valoración está {amount} puntos por encima de ese rango.", + "deviationBelow": "Tu propia valoración está {amount} puntos por debajo de ese rango.", + "weightedTitle": "Lo que más pesó en la comparación de este día", + "weightedUp": "{label}: contó hacia un valor esperado más alto", + "weightedDown": "{label}: contó hacia un valor esperado más bajo", + "caveat": "Es una comparación con tus propios días anteriores, no una afirmación sobre cómo llegó a transcurrir un día.", + "learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.", + "noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Registrar el contexto del día hace comparable una parte mayor.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/fr.json b/messages/fr.json index 219ed0de9..84706bb84 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3785,6 +3785,25 @@ "statement": "Les jours avec « {label} », l'humeur était en moyenne {direction} : {withAvg} contre {withoutAvg}.", "counts": "{withDays} jours avec, {withoutDays} jours sans", "caveat": "Cela montre un lien, pas une cause." + }, + "prognosis": { + "title": "Ton évaluation et ce que suggèrent tes journées passées", + "selfLabel": "Ton évaluation", + "expectedLabel": "Attendu selon tes propres habitudes", + "band": "Plage de {low} à {high}", + "statement": "D'après tes habitudes jusqu'ici, une valeur autour de {value} aurait été attendue pour {date}. Établi à partir de {n} journées réunissant tout ce dont la comparaison a besoin.", + "provisional": "Encore provisoire : le motif repose sur une courte série de journées.", + "notToday": "Cela décrit {date}, pas aujourd'hui.", + "deviationWithin": "Ta propre évaluation se situe dans cette plage.", + "deviationAbove": "Ta propre évaluation se situe {amount} points au-dessus de cette plage.", + "deviationBelow": "Ta propre évaluation se situe {amount} points en dessous de cette plage.", + "weightedTitle": "Ce qui a le plus pesé dans la comparaison de cette journée", + "weightedUp": "{label} — comptait vers une valeur attendue plus élevée", + "weightedDown": "{label} — comptait vers une valeur attendue plus basse", + "caveat": "C'est une comparaison avec tes propres journées passées, pas une affirmation sur la manière dont une journée s'est déroulée.", + "learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.", + "noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Noter le contexte d'une journée en rend davantage comparable.", + "featureValue": "{field} : {value}" } }, "derived": { diff --git a/messages/it.json b/messages/it.json index e3f206ded..73f207207 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3785,6 +3785,25 @@ "statement": "Nei giorni con «{label}» l'umore è stato in media {direction}: {withAvg} contro {withoutAvg}.", "counts": "{withDays} giorni con, {withoutDays} giorni senza", "caveat": "Questo mostra una relazione, non una causa." + }, + "prognosis": { + "title": "La tua valutazione e ciò che suggeriscono le tue giornate passate", + "selfLabel": "La tua valutazione", + "expectedLabel": "Atteso in base alle tue abitudini", + "band": "Intervallo da {low} a {high}", + "statement": "In base alle tue abitudini finora, per {date} ci si sarebbe attesi un valore intorno a {value}. Ricavato da {n} giornate che contengono tutto ciò che serve al confronto.", + "provisional": "Ancora provvisorio: lo schema si appoggia su poche giornate.", + "notToday": "Questo descrive {date}, non oggi.", + "deviationWithin": "La tua valutazione rientra in questo intervallo.", + "deviationAbove": "La tua valutazione è {amount} punti sopra questo intervallo.", + "deviationBelow": "La tua valutazione è {amount} punti sotto questo intervallo.", + "weightedTitle": "Che cosa ha pesato di più nel confronto di questa giornata", + "weightedUp": "{label} — contava verso un valore atteso più alto", + "weightedDown": "{label} — contava verso un valore atteso più basso", + "caveat": "È un confronto con le tue giornate passate, non un'affermazione su come una giornata sia andata a finire così.", + "learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.", + "noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Registrare il contesto della giornata rende confrontabile una parte maggiore.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/pl.json b/messages/pl.json index 80efa43b8..cd8c54d1a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3785,6 +3785,25 @@ "statement": "W dniach z „{label}” nastrój był średnio {direction}: {withAvg} wobec {withoutAvg}.", "counts": "{withDays} dni z, {withoutDays} dni bez", "caveat": "To pokazuje związek, nie przyczynę." + }, + "prognosis": { + "title": "Twoja ocena i to, co sugerują twoje dotychczasowe dni", + "selfLabel": "Twoja ocena", + "expectedLabel": "Oczekiwane na podstawie twoich wzorców", + "band": "Zakres od {low} do {high}", + "statement": "Na podstawie twoich dotychczasowych wzorców dla {date} należałoby oczekiwać wartości około {value}. Wyliczone z {n} dni, które zawierają wszystko, czego potrzebuje porównanie.", + "provisional": "Jeszcze wstępne: wzorzec opiera się na krótkim odcinku dni.", + "notToday": "To opisuje {date}, nie dzisiaj.", + "deviationWithin": "Twoja własna ocena mieści się w tym zakresie.", + "deviationAbove": "Twoja własna ocena leży {amount} punktów powyżej tego zakresu.", + "deviationBelow": "Twoja własna ocena leży {amount} punktów poniżej tego zakresu.", + "weightedTitle": "Co najbardziej zaważyło w porównaniu tego dnia", + "weightedUp": "{label} — liczyło się w stronę wyższej oczekiwanej wartości", + "weightedDown": "{label} — liczyło się w stronę niższej oczekiwanej wartości", + "caveat": "To porównanie z twoimi wcześniejszymi dniami, nie stwierdzenie, jak doszło do takiego przebiegu dnia.", + "learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.", + "noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z tym dzień. Zapisywanie kontekstu dnia sprawia, że więcej da się porównać.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/src/__tests__/copy-matches-behaviour-guard.test.ts b/src/__tests__/copy-matches-behaviour-guard.test.ts index 345f18467..da87e9644 100644 --- a/src/__tests__/copy-matches-behaviour-guard.test.ts +++ b/src/__tests__/copy-matches-behaviour-guard.test.ts @@ -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 = { + 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> + ).mood.prognosis as Record; + 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 = { + 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"); + }); +}); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 27a40aa5a..a4f0eb3d9 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/app/api/mood/prognosis/route.ts b/src/app/api/mood/prognosis/route.ts new file mode 100644 index 000000000..3852d25c8 --- /dev/null +++ b/src/app/api/mood/prognosis/route.ts @@ -0,0 +1,63 @@ +/** + * `GET /api/mood/prognosis` — the day's two readings, side by side. + * + * The person's own rating leads and is returned exactly as it was stored. The + * forecast rides beside it with its band, the number of days it was built + * from, and how old it is. The two are never merged: the distance between them + * is the whole finding, and a single blended number would destroy it. + * + * **This route declares `record`, not `mind`, and that is the security + * decision on it.** The payload is not only mood: the fit reads the day's + * level-B context AND the figures the sleep, activity and recovery modules + * hold, and the contributions it publishes name those features one by one — a + * reader can see that sleep or steps or resting heart rate carried weight on a + * given day, and roughly how much. Declared `mind` it would be a hole with a + * mood-shaped label on it: a delegate holding only the mind section would + * receive a derived view of two sections the owner had declined to share. The + * convention for a read that crosses sections is `record` + * (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest` + * are the precedents) and a scoped grant never reaches one, so a scoped + * delegate loses the forecast entirely rather than receiving a filtered + * version of it. A filtered version would be worse: a contribution list with + * the linked features quietly removed would tell the reader that the model + * weighted only the mood fields, which is false. + * + * Nothing here writes. The rows come from the nightly job and the user cannot + * overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts` + * holds that, and this file is one of the ones it scans. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + // `record`: see the file header. A scoped grant is refused here by + // construction rather than served a narrowed payload. + const { user } = await requireRecordAuth("read", "record"); + + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + + const reading = await readMoodPrognosis(user.id, new Date(), user.timezone); + + annotate({ + action: { name: "mood.prognosis.read" }, + meta: reading.present + ? { + present: true, + day: reading.date, + fit_rows: reading.n, + age_days: reading.ageDays, + current: reading.current, + provisional: reading.provisional, + deviation: reading.deviation, + } + : { present: false, reason: reading.reason, entries: reading.entries }, + }); + + return apiSuccess(reading); +}); diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 6573fff92..50886b099 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -36,6 +36,21 @@ const MoodChart = dynamic( { ssr: false, loading: () => }, ); +/** + * The day's two readings. Deferred like every other block on this route that + * is not the first paint: it reads its own endpoint and renders below the + * calendar, so nothing about it belongs in the eager graph of a page whose + * first paint is a heatmap. This route sits close to its bundle ceiling and + * every addition to it goes through the same treatment. + */ +const MoodPrognosisCard = dynamic( + () => + import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({ + default: mod.MoodPrognosisCard, + })), + { ssr: false, loading: () => null }, +); + interface ComprehensiveMoodData { moodSummary: { count: number } | null; } @@ -137,6 +152,12 @@ export default function InsightsStimmungPage() { + {/* The day's own rating beside what the account's past days imply. It + sits high because the self-assessment is the leading value on this + page, and directly under the line chart because that is where a + reader has just seen the rating it leads with. */} + + {/* The better-days Einschätzung sits directly under the Ziel card, ahead of the classification tiles and breakdowns. */} diff --git a/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx new file mode 100644 index 000000000..ac0d87f8c --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx @@ -0,0 +1,164 @@ +/** + * What reaches the screen, not what the strings say. + * + * The copy guard reads the bundles; this reads the render. Between them they + * cover the two halves of the same rule — that the two readings of a day are + * shown side by side and never merged, that the forecast never appears without + * its band and its count, and that the deviation is measured against the band + * rather than against the midpoint. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read"; + +import { MoodPrognosisView } from "../mood-prognosis-card"; + +function render(reading: PrognosisReading): string { + return renderToStaticMarkup( + + + , + ); +} + +function present(over: Partial = {}): PrognosisReading { + return { + present: true, + date: "2026-08-07", + predicted: 5.4, + ciLow: 4.1, + ciHigh: 6.8, + n: 42, + modelVersion: "mood-ridge-1", + computedAt: "2026-08-08T02:35:00.000Z", + ageDays: 1, + current: true, + provisional: false, + stage: "regular", + entries: 64, + seasonalUnlocked: false, + selfAssessment: 6, + deviation: "within", + deviationAmount: 0, + contributions: [ + { feature: "dimension:a2", contribution: 0.9 }, + { feature: "linked:steps", contribution: -0.4 }, + ], + ...over, + } as PrognosisReading; +} + +describe("", () => { + it("shows both readings, and shows the person's own one first", () => { + const html = render(present()); + expect(html).toContain('data-slot="mood-prognosis-self"'); + expect(html).toContain('data-slot="mood-prognosis-expected"'); + // The self block precedes the expected block in the document, which is + // what "the self-assessment leads" means to a screen reader. + expect(html.indexOf('data-slot="mood-prognosis-self"')).toBeLessThan( + html.indexOf('data-slot="mood-prognosis-expected"'), + ); + // Both values are on screen as themselves. A merged single figure is the + // failure this whole feature is shaped to avoid. + expect(html).toContain("6.0"); + expect(html).toContain("5.4"); + }); + + it("never shows the value without its band and its count", () => { + const html = render(present()); + expect(html).toContain("4.1"); + expect(html).toContain("6.8"); + expect(html).toContain("42"); + }); + + it("phrases the forecast as a counterfactual", () => { + const html = render(present()); + expect(html).toContain("would have been expected"); + expect(html).not.toContain("your mood is"); + }); + + it("measures the deviation against the band, not the midpoint", () => { + // 6.0 is above the 5.4 midpoint and inside the 4.1-6.8 band, so it is not + // a finding. A midpoint comparison would have called it one. + expect(render(present())).toContain("inside that range"); + + const above = render( + present({ selfAssessment: 8, deviation: "above", deviationAmount: 1.2 }), + ); + expect(above).toContain("1.2 points above"); + }); + + it("labels a provisional forecast as provisional", () => { + expect(render(present({ provisional: true }))).toContain( + 'data-slot="mood-prognosis-provisional"', + ); + expect(render(present())).not.toContain( + 'data-slot="mood-prognosis-provisional"', + ); + }); + + it("says which day it is about when that day is not today", () => { + const stale = render(present({ current: false, ageDays: 4 })); + expect(stale).toContain('data-slot="mood-prognosis-age"'); + expect(stale).toContain("2026-08-07"); + }); + + it("names what the model weighted, in the reader's own words", () => { + const html = render(present()); + // The dimension's own label, the one the slider carries. + expect(html).toContain("Stress"); + expect(html).toContain("Steps"); + expect(html).toContain("counted towards a higher expected value"); + expect(html).toContain("counted towards a lower expected value"); + }); + + it("skips a feature key this build no longer knows", () => { + const html = render( + present({ + contributions: [ + { feature: "retired:whatever", contribution: 2 }, + { feature: "dimension:a3", contribution: 1 }, + ], + }), + ); + expect(html).not.toContain("retired:whatever"); + expect(html).toContain('data-slot="mood-prognosis-explanation"'); + }); + + it("renders nothing at all below the first rung", () => { + expect( + render({ + present: false, + reason: "no-output-yet", + entries: 4, + nextThreshold: 15, + }), + ).toBe(""); + }); + + it("explains the learning phase with the count and the next step", () => { + const html = render({ + present: false, + reason: "learning-phase", + entries: 22, + nextThreshold: 30, + }); + expect(html).toContain("22"); + expect(html).toContain("30"); + // Absence, not an error and not a zero. + expect(html).not.toContain('data-slot="mood-prognosis-expected"'); + }); + + it("refuses honestly when there are days but no pattern", () => { + const html = render({ + present: false, + reason: "no-pattern", + entries: 140, + nextThreshold: null, + }); + expect(html).toContain("140"); + expect(html).not.toContain('data-slot="mood-prognosis-statement"'); + }); +}); diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx new file mode 100644 index 000000000..a4343e2b3 --- /dev/null +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -0,0 +1,241 @@ +"use client"; + +/** + * The day's two readings, side by side, with the person's own one leading. + * + * The copy rules this component exists to hold, stated once so nobody has to + * infer them from the strings: + * + * * **No sentence names a cause.** Not "overtime lowered your mood", not + * "poor sleep is why today was hard". What the data supports is "on your + * own past days, a value around X would have been expected", and that is + * what it says. The ban is a test, not a habit: + * `src/__tests__/copy-matches-behaviour-guard.test.ts` reads the six + * bundles and fails on a causal construction in any of them. + * * **The forecast is a counterfactual and the sentence says so.** Never + * "your mood is 5.4" — always "a value around 5.4 would have been + * expected". The difference is the whole distinction between the two + * readings. + * * **The count is on the same surface as the number**, not in a tooltip. A + * forecast built from thirty days and one built from three hundred look + * identical otherwise, and a reader who cannot see which one they are + * looking at cannot weigh it. + * * **The deviation is measured against the band, never against the + * midpoint.** Inside the band is "as expected"; outside it is the finding. + * + * The explanation is generated from the contributions the job stored for that + * day, so it names what the model actually weighted rather than a fresh + * calculation against a model that may since have been re-fitted. + */ +import { useQuery } from "@tanstack/react-query"; +import { Compass } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read"; + +import { prognosisFeatureLabel } from "./prognosis-feature-label"; + +/** How many weighted features the explanation names. */ +const EXPLAINED_FEATURES = 3; + +function formatValue(value: number, locale: string): string { + return new Intl.NumberFormat(locale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(value); +} + +export function MoodPrognosisCard() { + const { isAuthenticated } = useAuth(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.moodPrognosis(), + queryFn: async () => apiGet("/api/mood/prognosis"), + enabled: isAuthenticated, + staleTime: 60_000, + }); + + if (isLoading) { + return ; + } + if (!data) return null; + return ; +} + +/** + * The card itself, over a resolved reading. + * + * Split from the query so the copy rules above can be asserted against real + * renders rather than against the strings alone: "the two values are never + * merged" is a property of what reaches the screen, and a bundle check cannot + * see it. + */ +export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) { + const { t, locale } = useTranslations(); + const data = reading; + + // Below the first rung there is nothing to say and the surface says nothing + // rather than showing an empty frame with an apology in it. + if (!data.present && data.reason === "no-output-yet") return null; + + return ( + + + + + + {!data.present ? ( +

+ {data.reason === "learning-phase" + ? t("insights.mood.prognosis.learning", { + entries: String(data.entries), + threshold: String(data.nextThreshold ?? 0), + }) + : t("insights.mood.prognosis.noPattern", { + entries: String(data.entries), + })} +

+ ) : ( + <> +
+ {/* The self-assessment leads: it is the larger number, it comes + first, and it is the only one of the two the person gave. */} +
+

+ {t("insights.mood.prognosis.selfLabel")} +

+

+ {data.selfAssessment === null + ? t("mood.dayContext.notRecorded") + : formatValue(data.selfAssessment, locale)} +

+
+
+

+ {t("insights.mood.prognosis.expectedLabel")} +

+

+ {formatValue(data.predicted, locale)} +

+ {/* The band and the count ride with the value, in the block a + screen reader reaches in the same breath. */} +

+ {data.ciLow !== null && data.ciHigh !== null + ? t("insights.mood.prognosis.band", { + low: formatValue(data.ciLow, locale), + high: formatValue(data.ciHigh, locale), + }) + : null} +

+
+
+ +

+ {t("insights.mood.prognosis.statement", { + value: formatValue(data.predicted, locale), + date: data.date, + n: String(data.n), + })} +

+ + {data.provisional ? ( +

+ {t("insights.mood.prognosis.provisional")} +

+ ) : null} + + {!data.current ? ( +

+ {t("insights.mood.prognosis.notToday", { date: data.date })} +

+ ) : null} + + {data.deviation !== null && data.selfAssessment !== null ? ( +

+ {data.deviation === "within" + ? t("insights.mood.prognosis.deviationWithin") + : data.deviation === "above" + ? t("insights.mood.prognosis.deviationAbove", { + amount: formatValue(data.deviationAmount ?? 0, locale), + }) + : t("insights.mood.prognosis.deviationBelow", { + amount: formatValue(data.deviationAmount ?? 0, locale), + })} +

+ ) : null} + + + +

+ {t("insights.mood.prognosis.caveat")} +

+ + )} +
+
+ ); +} + +function PrognosisExplanation({ + contributions, +}: { + contributions: Array<{ feature: string; contribution: number }>; +}) { + const { t } = useTranslations(); + const named = contributions + .map((item) => ({ + ...item, + label: prognosisFeatureLabel(item.feature, t), + })) + .filter( + (item): item is typeof item & { label: string } => + item.label !== null && item.contribution !== 0, + ) + .slice(0, EXPLAINED_FEATURES); + + if (named.length === 0) return null; + + return ( +
+

+ {t("insights.mood.prognosis.weightedTitle")} +

+
    + {named.map((item) => ( +
  • + {item.contribution > 0 + ? t("insights.mood.prognosis.weightedUp", { label: item.label }) + : t("insights.mood.prognosis.weightedDown", { + label: item.label, + })} +
  • + ))} +
+
+ ); +} diff --git a/src/components/insights/mood/prognosis-feature-label.ts b/src/components/insights/mood/prognosis-feature-label.ts new file mode 100644 index 000000000..6c5d912a4 --- /dev/null +++ b/src/components/insights/mood/prognosis-feature-label.ts @@ -0,0 +1,71 @@ +/** + * A stored feature key, in the reader's own language. + * + * The keys in `MoodPrediction.features` are stable identifiers, not copy. This + * turns one into a label, and it does so out of the strings the capture + * surfaces already use: a person reads "Workload" here because that is what + * the slider they moved was called, not because a second wording was invented + * for the explanation. + * + * Every key it cannot place returns `null`, and that path is real rather than + * defensive: a row written by an older `modelVersion` can name a feature this + * build has dropped, and skipping it is better than rendering an identifier at + * somebody. + */ +import { parseFeatureKey } from "@/lib/analytics/mood-prognosis/feature-keys"; +import { contextValueLabelKey } from "@/lib/mood/context-vocabulary"; +import { getMoodDimension } from "@/lib/mood/dimensions"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +/** + * The five linked figures, resolved through literal keys. + * + * A switch rather than a computed key so the call-site coverage guard can see + * every string this file can ask for. The keys are the capture sheet's own + * (`mood.dayContext.linked*`), which is the point: the label a figure carries + * in the explanation is the label it carries where it was read. + */ +function linkedLabel(metric: string, t: Translate): string | null { + switch (metric) { + case "sleepAsleep": + return t("mood.dayContext.linkedSleep"); + case "steps": + return t("mood.dayContext.linkedSteps"); + case "activeEnergy": + return t("mood.dayContext.linkedActiveEnergy"); + case "restingHeartRate": + return t("mood.dayContext.linkedRestingHeartRate"); + case "heartRateVariability": + return t("mood.dayContext.linkedHrv"); + default: + return null; + } +} + +export function prognosisFeatureLabel( + key: string, + t: Translate, +): string | null { + const parsed = parseFeatureKey(key); + if (parsed === null) return null; + + if (parsed.family === "dimension") { + const dimension = getMoodDimension(parsed.dimension); + return dimension ? t(dimension.labelKey) : null; + } + if (parsed.family === "linked") return linkedLabel(parsed.metric, t); + + const field = t(`mood.context.field.${parsed.field}`); + if (parsed.value === null) return field; + // "Working day: longer than usual" — the field and the value the person + // picked, joined by a pattern rather than by a hard-coded colon, because the + // punctuation between them is a locale's business. + return t("insights.mood.prognosis.featureValue", { + field, + value: t(contextValueLabelKey(parsed.field, parsed.value)), + }); +} diff --git a/src/lib/analytics/mood-prognosis/read.ts b/src/lib/analytics/mood-prognosis/read.ts new file mode 100644 index 000000000..6bf0d16ab --- /dev/null +++ b/src/lib/analytics/mood-prognosis/read.ts @@ -0,0 +1,207 @@ +/** + * What the surfaces read: the two values of a day, side by side, never merged. + * + * The self-assessment is the leading value and is returned exactly as it was + * stored. The prognosis is the second reading — computed, banded, counted, and + * not editable. The deviation between them is the finding, and it is measured + * against the BAND rather than against the midpoint: a rating inside the band + * is a day that went as the person's own past days suggest, and only a rating + * outside it is worth a sentence. + * + * One reader for every surface. The route, the Coach snapshot and the daily + * briefing all come through here, so a change to what "the day's forecast" + * means moves all three together instead of two of them. + * + * Absence is explicit and carries its reason and its count. `no-output-yet` + * and `learning-phase` come from the ladder; `no-pattern` is the honest answer + * when there are plenty of days and nothing in them the fit could hold on to. + * None of the three is an error and none of them is a zero. + */ +import { prisma } from "@/lib/db"; +import { + dayKeyAgeInDays, + isCurrentForTodayClaim, +} from "@/lib/insights/measurement-freshness"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; + +import type { FeatureContribution } from "./forecast"; +import { countQualifyingDays } from "./load"; +import { + forecastGate, + seasonalComparisonsUnlocked, + type PrognosisStage, +} from "./thresholds"; + +/** How the day's own rating sat against the band. */ +export type PrognosisDeviation = "within" | "above" | "below"; + +export interface PrognosisPresent { + present: true; + /** The day the forecast is about, `YYYY-MM-DD`. */ + date: string; + /** The expected pleasantness, 0-10. Never shown without `n` and the band. */ + predicted: number; + ciLow: number | null; + ciHigh: number | null; + /** Complete days the fit used. Displayed wherever `predicted` is. */ + n: number; + modelVersion: string; + computedAt: string; + /** + * Whole days between the forecast's day and the reader's today. A forecast + * about the day before yesterday cannot back a sentence about today, and + * `current` is the same rule every other present-tense claim in the product + * uses — one constant, not a second one. + */ + ageDays: number | null; + current: boolean; + /** Below the regular threshold the forecast is labelled provisional. */ + provisional: boolean; + stage: PrognosisStage; + /** Days of history the ladder was read against. */ + entries: number; + /** Whether weekday and seasonal comparisons have unlocked. */ + seasonalUnlocked: boolean; + /** The person's own rating for that day. `null` when they gave none. */ + selfAssessment: number | null; + /** Where that rating sat against the band. `null` without a rating. */ + deviation: PrognosisDeviation | null; + /** How far outside the band it sat, in scale points. `0` when inside. */ + deviationAmount: number | null; + /** What the model weighted for that day, largest absolute first. */ + contributions: FeatureContribution[]; +} + +export interface PrognosisAbsent { + present: false; + /** + * `no-output-yet` — too few days for anything at all. + * `learning-phase` — enough to describe, not enough to forecast. + * `no-pattern` — enough days, nothing in them the fit could hold on to. + */ + reason: "no-output-yet" | "learning-phase" | "no-pattern"; + /** Days of history carrying a self-rating. */ + entries: number; + /** Days at which the next step unlocks, when a count is what is missing. */ + nextThreshold: number | null; +} + +export type PrognosisReading = PrognosisPresent | PrognosisAbsent; + +/** Parse the stored contributions. A malformed value reads as none. */ +export function parseContributions(stored: string): FeatureContribution[] { + try { + const parsed: unknown = JSON.parse(stored); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item): item is FeatureContribution => + typeof item === "object" && + item !== null && + typeof (item as FeatureContribution).feature === "string" && + typeof (item as FeatureContribution).contribution === "number", + ); + } catch { + return []; + } +} + +/** Where a rating sits against a band. */ +export function deviationAgainstBand( + actual: number, + low: number | null, + high: number | null, +): { deviation: PrognosisDeviation; amount: number } | null { + if (low === null || high === null) return null; + if (actual > high) return { deviation: "above", amount: actual - high }; + if (actual < low) return { deviation: "below", amount: low - actual }; + return { deviation: "within", amount: 0 }; +} + +/** + * The newest forecast an account holds, with the day's own rating beside it. + * + * The job writes a trailing window and the newest row in it is normally + * yesterday's — the pass runs before anybody has logged today. That is why + * the age travels with the value instead of being assumed to be zero. + */ +export async function readMoodPrognosis( + userId: string, + now: Date = new Date(), + timezone?: string | null, +): Promise { + const row = await prisma.moodPrediction.findFirst({ + where: { userId }, + orderBy: { date: "desc" }, + select: { + date: true, + predicted: true, + ciLow: true, + ciHigh: true, + n: true, + modelVersion: true, + features: true, + computedAt: true, + }, + }); + + if (row === null) { + // No row is not automatically "too few days": an account can have a year + // of entries and still get no forecast, because nothing in them cleared + // the screening. The count decides which of the three answers is true. + const entries = await countQualifyingDays(userId, now); + const gate = forecastGate(entries); + if (!gate.present) { + return { + present: false, + reason: gate.reason, + entries, + nextThreshold: gate.nextThreshold, + }; + } + return { + present: false, + reason: "no-pattern", + entries, + nextThreshold: null, + }; + } + + const [entries, entry] = await Promise.all([ + countQualifyingDays(userId, now), + prisma.moodEntry.findFirst({ + where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } }, + orderBy: { moodLoggedAt: "desc" }, + select: { moodA1: true }, + }), + ]); + + const today = moodDateKey(now, timezone ?? DEFAULT_TIMEZONE); + const ageDays = dayKeyAgeInDays(row.date, today); + const gate = forecastGate(entries); + const selfAssessment = entry?.moodA1 ?? null; + const against = + selfAssessment === null + ? null + : deviationAgainstBand(selfAssessment, row.ciLow, row.ciHigh); + + return { + present: true, + date: row.date, + predicted: row.predicted, + ciLow: row.ciLow, + ciHigh: row.ciHigh, + n: row.n, + modelVersion: row.modelVersion, + computedAt: row.computedAt.toISOString(), + ageDays, + current: isCurrentForTodayClaim(ageDays), + provisional: gate.present ? gate.provisional : true, + stage: gate.stage, + entries, + seasonalUnlocked: seasonalComparisonsUnlocked(entries), + selfAssessment, + deviation: against?.deviation ?? null, + deviationAmount: against?.amount ?? null, + contributions: parseContributions(row.features), + }; +} diff --git a/src/lib/query-keys/mood.ts b/src/lib/query-keys/mood.ts index bde824988..99ac6989e 100644 --- a/src/lib/query-keys/mood.ts +++ b/src/lib/query-keys/mood.ts @@ -58,6 +58,18 @@ export const moodKeys = { moodLinkedContext: (date: string, tz: string) => ["mood-linked-context", date, tz] as const, + /** + * v1.38 — the day's two readings: the person's own rating and what their + * past days imply, with the band and the case count. + * + * Under the `["mood-entries"]` prefix so `moodDependentKeys` reaches it: the + * self-assessment half of the payload comes straight off a mood row, and an + * edit to today's entry changes the deviation the card shows. The forecast + * half does not move until the nightly job runs again, which is why the read + * is cheap enough to refetch on a mood write. + */ + moodPrognosis: () => ["mood-entries", "prognosis"] as const, + moodAnalytics: () => ["mood-analytics"] as const, /** * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, From c33f7f9f6a0e4b4eaf1862d1eb64981f5c7f102c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:15:18 +0200 Subject: [PATCH 06/23] feat(mood): let the Coach read the deviation, and publish the resolved forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot gains the day's two readings with the age of the second one, through the same reader the page uses, so the page and the assistant cannot disagree about what today's comparison is. The freshness rule is the shared one: a comparison about the day before yesterday carries the date it is about and an instruction not to state it as today. Absence is a present field rather than a missing key — a model told "there is none yet" says so, and a model told nothing estimates one. The wording rule travels with the values, because a model handed a number and no instruction writes the sentence its training suggests, and about a forecast that sentence is a causal one. The contract publishes the resolved value: the expected figure, the band, the count, the deviation against the band, the age, and the contributions that carried weight. Nothing is left for a client to re-derive — two clients deriving the same figure about somebody's health record is how they start disagreeing about it. The description carries the observational wording and the display rule: the count goes wherever the value goes. The end-to-end proof runs against real Postgres and reads the table rather than a return value: nothing written a day short of the threshold, a banded and counted row on the day it crosses, the provisional label dropped at sixty days, the rows cleared when the module goes off, the rows cleared when entries are deleted back below the threshold, and an older pass's row replaced rather than left beside a fresh one. A delegate holding only the mind section is refused, and the assertion is on the response bytes. --- docs/api/openapi.yaml | 200 +++++++++ src/app/api/mood/prognosis/route.ts | 11 +- src/lib/insights/features.ts | 119 ++++++ src/lib/openapi/routes/mood.ts | 127 ++++++ .../mood-dimension-features.test.ts | 63 +++ .../integration/mood-prognosis-ladder.test.ts | 380 ++++++++++++++++++ 6 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 tests/integration/mood-prognosis-ladder.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 70132f280..2c66a7945 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8357,6 +8357,28 @@ paths: "403": *a6 "422": *a3 "429": *a4 + /api/mood/prognosis: + get: + tags: + - Mood + summary: The day's own rating and what the account's days imply (v1.38) + description: "The newest stored comparison for the account, with the person's own rating for that day beside it. Written + nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings + separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error + — the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's + context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a + section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood + module is off." + responses: + "200": + description: The day's two readings, or an explained absence. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodPrognosisEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/mood-entries/bulk: post: tags: @@ -32171,6 +32193,184 @@ components: - generatedAt additionalProperties: false description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis. + MoodPrognosisEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodPrognosis" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodPrognosis: + anyOf: + - type: object + properties: + present: + type: boolean + const: true + date: + type: string + description: The local day this is about, `YYYY-MM-DD`. + predicted: + type: number + description: The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual, + never a measurement — and never to be displayed without `n` and the band. + ciLow: + anyOf: + - type: number + - type: "null" + ciHigh: + anyOf: + - type: number + - type: "null" + description: The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal + to present the value cautiously. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and + one built from 300 look identical otherwise." + modelVersion: + type: string + computedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + ageDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Whole days between that day and the reader's today. + current: + type: boolean + description: Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is + about. + provisional: + type: boolean + description: "True below 60 days of history: label it as provisional." + stage: + type: string + enum: + - provisional + - regular + - seasonal + entries: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days of history behind the ladder. + seasonalUnlocked: + type: boolean + selfAssessment: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on + any surface that shows both, and it is never merged with `predicted`." + deviation: + anyOf: + - type: string + enum: + - within + - above + - below + - type: "null" + description: Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the + account's own past days suggest. + deviationAmount: + anyOf: + - type: number + - type: "null" + description: How far outside the band, in scale points. `0` when inside. + contributions: + type: array + items: + $ref: "#/components/schemas/MoodPrognosisContribution" + required: + - present + - date + - predicted + - ciLow + - ciHigh + - n + - modelVersion + - computedAt + - ageDays + - current + - provisional + - stage + - entries + - seasonalUnlocked + - selfAssessment + - deviation + - deviationAmount + - contributions + additionalProperties: false + - type: object + properties: + present: + type: boolean + const: false + reason: + type: string + enum: + - no-output-yet + - learning-phase + - no-pattern + description: "`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare. + `no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a + zero." + entries: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + nextThreshold: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Days at which the next step unlocks, when a count is missing. + required: + - present + - reason + - entries + - nextThreshold + additionalProperties: false + description: "Two separate readings of one day: what the person recorded, and what a comparison with their own past days + would have expected. They are never merged — the distance between them is the finding, and a single blended + figure destroys it. The comparison is a regularised regression over the account's own history: observational + only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have + been expected), must carry `n`, and must not say that anything caused anything." + MoodPrognosisContribution: + type: object + properties: + feature: + type: string + description: "Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`. + A client that does not recognise one skips it — an older row can name a feature a later model dropped." + contribution: + type: number + description: "Signed: how far this feature moved the expected value away from the account's own average day. Ordered by + absolute size, largest first." + required: + - feature + - contribution + additionalProperties: false BulkMoodResponseEnvelope: type: object properties: diff --git a/src/app/api/mood/prognosis/route.ts b/src/app/api/mood/prognosis/route.ts index 3852d25c8..2129e61e7 100644 --- a/src/app/api/mood/prognosis/route.ts +++ b/src/app/api/mood/prognosis/route.ts @@ -26,6 +26,8 @@ * overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts` * holds that, and this file is one of the ones it scans. */ +import type { NextRequest } from "next/server"; + import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read"; @@ -34,7 +36,14 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; -export const GET = apiHandler(async () => { +// The request is declared although the handler reads nothing off it: the +// wrapper takes the method and the path from it for the wide event, and +// `requireRecordAuth` reads the method to decide whether a `read` declaration +// really is a read. A handler with no parameter types as zero-arity, and a +// caller that then omits the request turns every method check into "unknown", +// which escalates a read to a write and refuses a delegate for the wrong +// reason. +export const GET = apiHandler(async (_request: NextRequest) => { // `record`: see the file header. A scoped grant is refused here by // construction rather than served a narrowed payload. const { user } = await requireRecordAuth("read", "record"); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 129fef2dc..b0598de15 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -17,6 +17,7 @@ import { userDayKey } from "@/lib/tz/format"; import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series"; import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab"; +import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read"; import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { @@ -321,6 +322,113 @@ async function buildMoodDimensionBlock(userId: string): Promise<{ }; } +/** + * The day's two readings for the model, with the age of the second one. + * + * Absence is a present field rather than a missing one: `{ present: false }` + * with the reason and the count, so the model says "not enough entries yet" + * instead of inventing a number. That shape is a contract elsewhere in the + * product already and it is the reason this block is built even when there is + * nothing to report. + * + * The wording rule travels with the values. A model handed a number and no + * instruction writes the sentence its training suggests, and the sentence its + * training suggests about a forecast is a causal one. + */ +interface MoodPrognosisFeature { + present: boolean; + /** Why there is nothing, when there is nothing. */ + reason?: string; + /** Days of the account's own history the ladder was read against. */ + entries: number; + /** The day the forecast is about. */ + date?: string; + /** The expected value, 0-10. Never to be stated without `n` and the band. */ + predicted?: number; + ciLow?: number | null; + ciHigh?: number | null; + /** Complete days the fit used. State it in any sentence that uses the value. */ + n?: number; + modelVersion?: string; + /** The person's own rating for that day, unchanged. */ + selfAssessment?: number | null; + /** Where that rating sat against the band: within, above or below. */ + deviation?: string | null; + /** Whole days between the forecast's day and today. */ + daysAgo?: number | null; + /** Whether it is fresh enough to back a present-tense sentence. */ + current?: boolean; + /** Present only when it is not, and says in words what the flag says. */ + staleNotice?: string; + /** The wording rule, stated rather than implied. */ + note: string; +} + +/** + * The forecast block, or an honest absence. + * + * One reader for every surface: this calls the same `readMoodPrognosis` the + * route calls, so the Coach and the page cannot disagree about what today's + * comparison is. The freshness rule is the shared one — `isCurrentForTodayClaim` + * against the same `TODAY_CLAIM_MAX_AGE_DAYS` — because a second constant for + * "how old may a value be before today stops being true about it" is how two + * surfaces start narrating the same reading differently. + */ +async function buildMoodPrognosisBlock(userId: string): Promise<{ + prognosis?: MoodPrognosisFeature; +}> { + const reading = await readMoodPrognosis( + userId, + new Date(), + await resolveUserTimezone(userId), + ); + + if (!reading.present) { + return { + prognosis: { + present: false, + reason: reading.reason, + entries: reading.entries, + note: + "There is no expected-value comparison for this account yet. Say so " + + "plainly if it comes up; do not estimate one, and do not describe " + + "the absence as a bad sign.", + }, + }; + } + + return { + prognosis: { + present: true, + entries: reading.entries, + date: reading.date, + predicted: reading.predicted, + ciLow: reading.ciLow, + ciHigh: reading.ciHigh, + n: reading.n, + modelVersion: reading.modelVersion, + selfAssessment: reading.selfAssessment, + deviation: reading.deviation, + daysAgo: reading.ageDays, + current: reading.current, + ...(reading.current + ? {} + : { + staleNotice: `This comparison is about ${reading.date}. State it as that day, with the date; do not describe it as today.`, + }), + note: + "Two separate readings of one day: `selfAssessment` is what the person " + + "recorded and leads; `predicted` is what a comparison with their own " + + "past days would have expected, built from `n` days. Never merge them, " + + "never present `predicted` as the person's mood, and never state it " + + "without `n` and the band. Phrase it as a counterfactual — a value " + + "around X would have been expected — and never say that anything " + + "caused anything: the comparison is an association over the account's " + + "own days and supports no cause.", + }, + }; +} + export interface AggregatedFeatures { weight?: { latest: number; @@ -467,6 +575,16 @@ export interface AggregatedFeatures { withoutAvg: number; }>; }; + /** + * v1.38 — the day's two readings and the distance between them. + * + * Always present once the mood module is on, because an honest absence is + * information: `present: false` with a reason and a count keeps the model + * from filling the gap with an estimate. The block carries its own age, so + * a comparison about the day before yesterday cannot back a sentence about + * today. + */ + prognosis?: MoodPrognosisFeature; }; sleep?: { avg7: number | null; @@ -1363,6 +1481,7 @@ export async function extractFeatures( totalEntries: moodTotalEntries, ...(await buildMoodDimensionBlock(userId)), ...(await buildMoodContextBlock(userId)), + ...(await buildMoodPrognosisBlock(userId)), coverage: { count: moodTotalEntries, spanDays, diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 1ab95c051..a562f05fc 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -386,7 +386,134 @@ const bulkMoodResponse = z }) .meta({ id: "BulkMoodResponse" }); +// ── The day's two readings ─────────────────────────────────────────── +// +// Published as the RESOLVED value. The server computes the expected value, the +// band, the count, the deviation and the age; the client renders them. Nothing +// here is a hint the client is meant to re-derive — two clients deriving the +// same figure is how they start disagreeing about it, and this figure is about +// somebody's health record. + +const moodPrognosisContribution = z + .object({ + feature: z + .string() + .describe( + "Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`. A client that does not recognise one skips it — an older row can name a feature a later model dropped.", + ), + contribution: z + .number() + .describe( + "Signed: how far this feature moved the expected value away from the account's own average day. Ordered by absolute size, largest first.", + ), + }) + .meta({ id: "MoodPrognosisContribution" }); + +const moodPrognosisPresent = z.object({ + present: z.literal(true), + date: z.string().describe("The local day this is about, `YYYY-MM-DD`."), + predicted: z + .number() + .describe( + "The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual, never a measurement — and never to be displayed without `n` and the band.", + ), + ciLow: z.number().nullable(), + ciHigh: z + .number() + .nullable() + .describe( + "The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal to present the value cautiously.", + ), + n: z + .number() + .int() + .describe( + "Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and one built from 300 look identical otherwise.", + ), + modelVersion: z.string(), + computedAt: z.iso.datetime({ offset: true }), + ageDays: z + .number() + .int() + .nullable() + .describe("Whole days between that day and the reader's today."), + current: z + .boolean() + .describe( + "Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is about.", + ), + provisional: z + .boolean() + .describe("True below 60 days of history: label it as provisional."), + stage: z.enum(["provisional", "regular", "seasonal"]), + entries: z.number().int().describe("Days of history behind the ladder."), + seasonalUnlocked: z.boolean(), + selfAssessment: z + .number() + .int() + .nullable() + .describe( + "The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on any surface that shows both, and it is never merged with `predicted`.", + ), + deviation: z + .enum(["within", "above", "below"]) + .nullable() + .describe( + "Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the account's own past days suggest.", + ), + deviationAmount: z + .number() + .nullable() + .describe("How far outside the band, in scale points. `0` when inside."), + contributions: z.array(moodPrognosisContribution), +}); + +const moodPrognosisAbsent = z.object({ + present: z.literal(false), + reason: z + .enum(["no-output-yet", "learning-phase", "no-pattern"]) + .describe( + "`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare. `no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a zero.", + ), + entries: z.number().int(), + nextThreshold: z + .number() + .int() + .nullable() + .describe("Days at which the next step unlocks, when a count is missing."), +}); + +const moodPrognosisResponse = z + .union([moodPrognosisPresent, moodPrognosisAbsent]) + .meta({ + id: "MoodPrognosis", + description: + "Two separate readings of one day: what the person recorded, and what a comparison with their own past days would have expected. They are never merged — the distance between them is the finding, and a single blended figure destroys it. The comparison is a regularised regression over the account's own history: observational only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have been expected), must carry `n`, and must not say that anything caused anything.", + }); + export const moodPaths: NonNullable = { + "/api/mood/prognosis": { + get: { + tags: ["Mood"], + summary: "The day's own rating and what the account's days imply (v1.38)", + description: + "The newest stored comparison for the account, with the person's own rating for that day beside it. Written nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error — the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood module is off.", + responses: { + "200": { + description: "The day's two readings, or an explained absence.", + content: { + "application/json": { + schema: dataEnvelope( + moodPrognosisResponse, + "MoodPrognosisEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/mood-entries/bulk": { post: { tags: ["Mood"], diff --git a/tests/integration/mood-dimension-features.test.ts b/tests/integration/mood-dimension-features.test.ts index b722528ee..1baadaf42 100644 --- a/tests/integration/mood-dimension-features.test.ts +++ b/tests/integration/mood-dimension-features.test.ts @@ -175,6 +175,69 @@ describe("level-A blocks reach the feature set with their own ages", () => { expect(features.mood?.dimensionsAsOfNote).toBeUndefined(); }); + it("carries the day's forecast with its own age, and its own instruction", async () => { + await seedUser(); + await seedEntry(0, { mood: "GUT", score: 4, a1: 7, a2: 3 }); + // A forecast about the day before yesterday. Written straight to the + // table: the job's own path is proven in `mood-prognosis-ladder.test.ts`, + // and what this case is about is what the assistant is handed. + await getPrismaClient().moodPrediction.create({ + data: { + userId: USER, + date: shiftDateKey(todayKey(), -3), + predicted: 5.4, + ciLow: 4.1, + ciHigh: 6.8, + n: 42, + modelVersion: "mood-ridge-1", + features: JSON.stringify([ + { feature: "dimension:a2", contribution: 0.9 }, + ]), + computedAt: new Date(Date.now() - 3 * DAY_MS), + }, + }); + + const features = await extractFeatures(USER, false); + const prognosis = features.mood?.prognosis; + expect( + prognosis, + "features.mood carries no prognosis block; the assistant sees neither the value nor the fact that there is none", + ).toBeDefined(); + expect(prognosis!.present).toBe(true); + expect(prognosis!.predicted).toBe(5.4); + expect(prognosis!.n).toBe(42); + expect(prognosis!.ciLow).toBe(4.1); + expect(prognosis!.ciHigh).toBe(6.8); + // Three days old: the same freshness rule the dimensions use, from the + // same module, so nothing may state it as today. + expect(prognosis!.daysAgo).toBe(3); + expect(prognosis!.current).toBe(false); + expect( + prognosis!.staleNotice, + "a stale forecast ships without the instruction that makes it safe to read", + ).toBeTruthy(); + expect(prognosis!.staleNotice).toContain(shiftDateKey(todayKey(), -3)); + expect(prognosis!.staleNotice!.toLowerCase()).toContain("do not describe"); + // And the wording rule rides with the values rather than being inferred. + expect(prognosis!.note.toLowerCase()).toContain("counterfactual"); + expect(prognosis!.note.toLowerCase()).toContain("never merge"); + }); + + it("states the absence of a forecast rather than omitting it", async () => { + await seedUser(); + await seedEntry(0, { mood: "GUT", score: 4, a1: 7 }); + + const features = await extractFeatures(USER, false); + const prognosis = features.mood?.prognosis; + expect(prognosis).toBeDefined(); + // Absence is a field, not a missing key: a model told "there is none yet" + // says so, and a model told nothing estimates one. + expect(prognosis!.present).toBe(false); + expect(prognosis!.reason).toBe("no-output-yet"); + expect(prognosis!.entries).toBe(1); + expect(prognosis!.predicted).toBeUndefined(); + }); + it("leaves the legacy scale line untouched", async () => { await seedUser(); await seedEntry(0, { mood: "GUT", score: 4, a1: 7 }); diff --git a/tests/integration/mood-prognosis-ladder.test.ts b/tests/integration/mood-prognosis-ladder.test.ts new file mode 100644 index 000000000..53314ae2c --- /dev/null +++ b/tests/integration/mood-prognosis-ladder.test.ts @@ -0,0 +1,380 @@ +/** + * The ladder, the job and the route, against real Postgres and the real rows. + * + * The unit tests prove the arithmetic and the gates in isolation. This proves + * the assembly: that an account one day short of the threshold really gets + * nothing written, that the day it crosses really gets a row, that switching + * the module off really takes the rows away again, and that deleting back + * below the threshold does not leave a forecast standing with a fresh date on + * it. Every one of those is a claim about what is IN THE TABLE, so every + * assertion reads the table rather than a return value. + * + * It is also the automated half of the ladder walk the phase plan asks a human + * to do. What is left for a browser is what a browser is for: that the band is + * legible at 390 px and that the count sits beside the number rather than in a + * tooltip. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +const USER = "user-mood-prognosis"; +const TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** Fixed clock: a ladder test that slides with the calendar is a date bomb. */ +const NOW = new Date("2026-08-08T09:00:00.000Z"); + +function dayKey(daysAgo: number): string { + return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10); +} + +async function seedUser(): Promise { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "mood-prognosis", + email: "mood-prognosis@example.test", + timezone: TZ, + }, + }); +} + +/** + * `count` consecutive days, each carrying a rating and a stress value the + * rating follows. A clean relationship on purpose: the point of this file is + * the plumbing, and a fixture the screening refuses would test the refusal + * path twice and the write path never. + */ +async function seedDays(count: number): Promise { + for (let i = 0; i < count; i++) { + const stress = i % 11; + await getPrismaClient().moodEntry.create({ + data: { + userId: USER, + date: dayKey(i), + tz: TZ, + mood: "OKAY", + score: 3, + moodA1: Math.round(0.8 * (10 - stress) + 1), + stressA2: stress, + source: "MANUAL", + moodLoggedAt: new Date(NOW.getTime() - i * DAY_MS), + }, + }); + } +} + +async function runJob() { + const { runMoodPrognosisRefresh } = + await import("@/lib/jobs/mood-prognosis-refresh"); + return runMoodPrognosisRefresh(NOW); +} + +function storedRows() { + return getPrismaClient().moodPrediction.findMany({ + where: { userId: USER }, + orderBy: { date: "asc" }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("the minimum-n ladder, end to end", () => { + it("writes nothing at one day short of the threshold, and says why", async () => { + await seedUser(); + await seedDays(29); + + const summary = await runJob(); + expect(summary.refreshed).toBe(0); + // The account is not in the cohort at all: the job's cheap cut is the same + // threshold, so an account a day short is never even read. The next case + // is the other half of the evidence — one more day and `scanned` is 1, so + // this zero is the cut landing in the right place rather than a sweep that + // matched nothing. + expect(summary.scanned).toBe(0); + expect(await storedRows()).toEqual([]); + + const { readMoodPrognosis } = + await import("@/lib/analytics/mood-prognosis/read"); + const reading = await readMoodPrognosis(USER, NOW, TZ); + expect(reading.present).toBe(false); + if (!reading.present) { + expect(reading.reason).toBe("learning-phase"); + expect(reading.entries).toBe(29); + expect(reading.nextThreshold).toBe(30); + } + }); + + it("writes a banded, counted, provisional forecast on the day it crosses", async () => { + await seedUser(); + await seedDays(30); + + const summary = await runJob(); + expect(summary.scanned).toBe(1); + expect(summary.refreshed).toBe(1); + + const rows = await storedRows(); + expect(rows.length).toBeGreaterThan(0); + // The trailing window, not the whole year of days. + expect(rows.length).toBeLessThanOrEqual(7); + + for (const row of rows) { + expect(row.predicted).toBeGreaterThanOrEqual(0); + expect(row.predicted).toBeLessThanOrEqual(10); + // The band and the count are part of the value, not decoration. + expect(row.ciLow).not.toBeNull(); + expect(row.ciHigh).not.toBeNull(); + expect(row.ciLow!).toBeLessThanOrEqual(row.predicted); + expect(row.ciHigh!).toBeGreaterThanOrEqual(row.predicted); + expect(row.n).toBe(30); + expect(row.modelVersion).toBe("mood-ridge-1"); + // The stored evidence the explanation is generated from. + const contributions = JSON.parse(row.features) as Array<{ + feature: string; + contribution: number; + }>; + expect(contributions.length).toBeGreaterThan(0); + expect(contributions[0].feature).toBe("dimension:a2"); + expect(typeof contributions[0].contribution).toBe("number"); + } + + const { readMoodPrognosis } = + await import("@/lib/analytics/mood-prognosis/read"); + const reading = await readMoodPrognosis(USER, NOW, TZ); + expect(reading.present).toBe(true); + if (reading.present) { + // Thirty days is a forecast, and a forecast at thirty days is labelled. + expect(reading.provisional).toBe(true); + expect(reading.stage).toBe("provisional"); + expect(reading.n).toBe(30); + // The self-assessment is returned unchanged beside it, never merged. + const entry = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: USER, date: reading.date }, + }); + expect(reading.selfAssessment).toBe(entry.moodA1); + expect(reading.deviation).not.toBeNull(); + } + }); + + it("drops the provisional label once the account has enough days", async () => { + await seedUser(); + await seedDays(70); + await runJob(); + + const { readMoodPrognosis } = + await import("@/lib/analytics/mood-prognosis/read"); + const reading = await readMoodPrognosis(USER, NOW, TZ); + expect(reading.present).toBe(true); + if (reading.present) { + expect(reading.provisional).toBe(false); + expect(reading.stage).toBe("regular"); + expect(reading.seasonalUnlocked).toBe(false); + } + }); + + it("computes nothing and stores nothing while the mood module is off", async () => { + await seedUser(); + await seedDays(40); + await runJob(); + expect((await storedRows()).length).toBeGreaterThan(0); + + await getPrismaClient().user.update({ + where: { id: USER }, + data: { modulePreferencesJson: { mood: false } }, + }); + + const summary = await runJob(); + expect(summary.moduleOff).toBe(1); + expect(summary.refreshed).toBe(0); + // And the rows it wrote before are gone: a forecast that outlives the + // switch that turned its module off is a claim nobody asked for. + expect(await storedRows()).toEqual([]); + }); + + it("clears a stale forecast when the account falls back below the threshold", async () => { + await seedUser(); + await seedDays(40); + await runJob(); + const before = await storedRows(); + expect(before.length).toBeGreaterThan(0); + + // Delete back under the threshold. The account is no longer in the + // eligibility half of the cohort at all, so the pass only reaches it + // through the rows it already holds. + await getPrismaClient().moodEntry.deleteMany({ + where: { userId: USER, date: { gte: dayKey(28) } }, + }); + + const summary = await runJob(); + expect(summary.scanned).toBe(1); + expect(summary.refused).toBe(1); + expect( + await storedRows(), + "the forecast outlived its own eligibility — a stale claim with a fresh date on it", + ).toEqual([]); + }); + + it("replaces rather than accumulates across passes", async () => { + await seedUser(); + await seedDays(40); + await runJob(); + const first = await storedRows(); + + // A row from a pass that will not be repeated: an older day, written by + // hand the way a previous window would have left it. + await getPrismaClient().moodPrediction.create({ + data: { + userId: USER, + date: dayKey(200), + predicted: 5, + ciLow: 4, + ciHigh: 6, + n: 99, + modelVersion: "mood-ridge-0", + features: "[]", + computedAt: new Date(NOW.getTime() - 200 * DAY_MS), + }, + }); + + await runJob(); + const after = await storedRows(); + expect(after.map((r) => r.date)).toEqual(first.map((r) => r.date)); + expect(after.some((r) => r.modelVersion === "mood-ridge-0")).toBe(false); + }); +}); + +describe("GET /api/mood/prognosis", () => { + /** + * A signed-in browser session. + * + * The expiry is off the REAL clock, not the fixture's: the seeded days are + * dated against `NOW` so the ladder cannot slide with the calendar, but a + * session stamped from a fixed instant expires the moment the wall clock + * passes it, and every route then answers 401 for a reason that has nothing + * to do with what the case is about. + * + * `actingOn` is passed only for a delegate. Switching an owner's session + * into their own record would make the request a delegated one against a + * grant that does not exist, and the refusal would look like the scope + * refusal this file is trying to prove. + */ + async function signIn(userId: string, actingOn?: string) { + const session = await getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + if (actingOn) await switchSessionTo(session.id, actingOn); + return session; + } + + it("answers the owner with both readings", async () => { + await seedUser(); + await seedDays(40); + await runJob(); + await signIn(USER); + + const { GET } = await import("@/app/api/mood/prognosis/route"); + const res = await GET( + new NextRequest("http://localhost/api/mood/prognosis"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { present: boolean; n: number; predicted: number; ciLow: number }; + }; + expect(body.data.present).toBe(true); + expect(body.data.n).toBe(40); + expect(typeof body.data.predicted).toBe("number"); + expect(typeof body.data.ciLow).toBe("number"); + }); + + it("refuses a delegate whose grant does not open the whole record", async () => { + await seedUser(); + await seedDays(40); + await runJob(); + const rows = await storedRows(); + expect(rows.length).toBeGreaterThan(0); + + const delegate = await getPrismaClient().user.create({ + data: { + id: "user-mood-prognosis-delegate", + username: "mood-prognosis-delegate", + email: "mood-prognosis-delegate@example.test", + timezone: TZ, + }, + }); + await getPrismaClient().accountGrant.create({ + data: { + grantorId: USER, + granteeId: delegate.id, + access: "READ", + acceptedAt: new Date(), + // Only the mind section. The forecast reads across sections and names + // what it read, so it is not a mind payload. + scopeJson: ["mind"] as never, + }, + }); + await signIn(delegate.id, USER); + + const { GET } = await import("@/app/api/mood/prognosis/route"); + const res = await GET( + new NextRequest("http://localhost/api/mood/prognosis"), + ); + expect(res.status).toBe(403); + + // Asserted on the bytes, not on a parsed field: the point is that not one + // figure of the owner's reached the response. + const raw = await res.text(); + expect(raw).not.toContain("predicted"); + expect(raw).not.toContain("ciLow"); + expect(raw).not.toContain("dimension:a2"); + expect(raw).not.toContain(String(rows[0].n)); + expect(raw).not.toContain(rows[0].date); + }); + + it("blanks the surface when the mood module is off", async () => { + await seedUser(); + await seedDays(40); + await runJob(); + await getPrismaClient().user.update({ + where: { id: USER }, + data: { modulePreferencesJson: { mood: false } }, + }); + await signIn(USER); + + const { GET } = await import("@/app/api/mood/prognosis/route"); + const res = await GET( + new NextRequest("http://localhost/api/mood/prognosis"), + ); + expect(res.status).toBe(403); + }); +}); From d45cedcf5d0c050eebd72d1d7ece4f8343a3e3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:15:44 +0200 Subject: [PATCH 07/23] docs(changelog): note the mood comparison and its refusals --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 154891c6c..364568d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ### Added +- The mood module can now say what a day would have looked like on your own + past patterns, and how far the day you recorded sits from that. It is a + second, separate reading: your own rating stays the leading one, it is + stored exactly as you gave it, and nothing overwrites it. The comparison + appears only once there are enough days to say anything honest, it always + carries the number of days it was built from and a range rather than a + single figure, and it names which of the things you recorded weighed most on + that day. It describes a connection between things you wrote down. It never + claims a cause. + + Below fifteen days there is nothing at all. Between fifteen and thirty the + page says how far along it is instead of guessing. Between thirty and sixty + the comparison is there and is labelled as provisional. Where the days are + there but nothing in them is steady enough to compare against, it says that + too, rather than producing a number to fill the space. The comparison is + rebuilt overnight from your entries, and if you delete enough of them it + goes away again instead of standing there with a fresh date on it. + - A mood entry can now carry the shape of the day around it. Four sections sit below the sliders — work, contacts, leisure, and one notable event — and every one of them is closed until you open it, so a quick check-in is still From 09bf0a194795286dd102f6138f470bee045777f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:30:13 +0200 Subject: [PATCH 08/23] fix(openapi): publish the sharing refusal on the prognosis read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every delegable operation states, in the contract, that it can refuse a delegate — and the guard over the document caught this one publishing only its module gate. One status, two reasons: the module gate's own sentence first, the shared refusal spliced on the end, which is the shape every other gated delegable route already uses. --- docs/api/openapi.yaml | 1 + src/lib/openapi/routes/mood.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2c66a7945..eda8dc2a5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8377,6 +8377,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 + "403": *a7 "422": *a3 "429": *a4 /api/mood-entries/bulk: diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index a562f05fc..7589199e4 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -35,7 +35,9 @@ import { import { dataEnvelope, errorEnvelope, + recordRefusal, stdResponses, + MODULE_DISABLED_DESCRIPTION, baseUpdatedAtField, updatedAtTokenField, conflictResponse409, @@ -510,6 +512,9 @@ export const moodPaths: NonNullable = { }, }, }, + // Two reasons, one status: the module gate and the sharing fence both + // answer 403, and OpenAPI allows one response per status. + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, From 13d808ccffddf70c2339e5371c7f4f35c865eeca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:37:14 +0200 Subject: [PATCH 09/23] fix(mood): bound the fit's read, and show the day as a date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small things the first pass left rough. The fit's window read had no row cap. A year holds 365 days and the fold keeps one entry per day, so the cap only bites on an account that logs many times a day — but an unbounded read in a nightly job is a table scan waiting for the account that has years of history. The rows now arrive newest first under a cap, which also puts the loss on the right end: an account over the cap loses its oldest days, never the recent ones a forecast is written for. And the card showed the raw day key. It goes through the account's own date-order preference now, pinned to UTC like every other day key in the app, so this card cannot show a different day from the list beside it. --- .../__tests__/mood-prognosis-card.test.tsx | 8 +++++- .../insights/mood/mood-prognosis-card.tsx | 13 +++++++-- src/lib/analytics/mood-prognosis/load.ts | 28 ++++++++++++++++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx index ac0d87f8c..7423ac228 100644 --- a/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx +++ b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx @@ -102,7 +102,13 @@ describe("", () => { it("says which day it is about when that day is not today", () => { const stale = render(present({ current: false, ageDays: 4 })); expect(stale).toContain('data-slot="mood-prognosis-age"'); - expect(stale).toContain("2026-08-07"); + // Rendered through the account's own date-order preference rather than as + // the raw day key — under this render's `en` locale and the default AUTO + // preference that is month-first. The assertion is on the formatted form + // precisely because a raw `2026-08-07` on the screen would pass a looser + // one. + expect(stale).toContain("08/07/2026"); + expect(stale).not.toContain("2026-08-07"); }); it("names what the model weighted, in the reader's own words", () => { diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx index a4343e2b3..4167d1941 100644 --- a/src/components/insights/mood/mood-prognosis-card.tsx +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -35,7 +35,8 @@ import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { formatDate } from "@/lib/date-format"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read"; @@ -78,7 +79,13 @@ export function MoodPrognosisCard() { */ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) { const { t, locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); const data = reading; + // The day key is a plain calendar date and is rendered as one, through the + // account's own date-order preference and pinned to UTC — the same treatment + // every other day key in the app gets, so this card cannot show a different + // day from the list beside it. + const day = data.present ? formatDate(data.date, dateFormat, locale) : ""; // Below the first rung there is nothing to say and the surface says nothing // rather than showing an empty frame with an apology in it. @@ -145,7 +152,7 @@ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) { > {t("insights.mood.prognosis.statement", { value: formatValue(data.predicted, locale), - date: data.date, + date: day, n: String(data.n), })}

@@ -164,7 +171,7 @@ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) { className="text-muted-foreground text-xs" data-slot="mood-prognosis-age" > - {t("insights.mood.prognosis.notToday", { date: data.date })} + {t("insights.mood.prognosis.notToday", { date: day })}

) : null} diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts index eb2ba1b99..f21bc319a 100644 --- a/src/lib/analytics/mood-prognosis/load.ts +++ b/src/lib/analytics/mood-prognosis/load.ts @@ -44,6 +44,17 @@ export const PROGNOSIS_FIT_WINDOW_DAYS = 365; */ export const PROGNOSIS_WRITE_WINDOW_DAYS = 7; +/** + * Hard cap on the rows one fit reads. + * + * A year holds 365 days and the fold keeps one entry per day, so the cap only + * ever bites on an account that logs many times a day — and there it bites on + * the oldest days, which is the right end to lose. It is here because an + * unbounded `findMany` in a nightly job over an account with years of history + * is a table scan waiting for the account that has one. + */ +export const MAX_PROGNOSIS_ROWS = 5000; + /** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */ export function dayKeyBefore(now: Date, days: number): string { return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10); @@ -67,10 +78,13 @@ export async function loadPrognosisDays( const since = dayKeyBefore(now, windowDays); const entries = await prisma.moodEntry.findMany({ where: { userId, deletedAt: null, date: { gte: since } }, - // Ascending, so a later entry on the same day overwrites the earlier one - // in the fold below and the "last entry of the day wins" rule is the - // read order rather than a comparison. - orderBy: [{ date: "asc" }, { moodLoggedAt: "asc" }], + // Descending, and the fold below keeps the FIRST row it sees per day, so + // "the last entry of a day wins" is the read order rather than a + // comparison. Descending also puts the cap on the right end: an account + // that logs many times a day loses its oldest days to the limit, never + // its newest ones, and the newest are the days a forecast is written for. + orderBy: [{ date: "desc" }, { moodLoggedAt: "desc" }], + take: MAX_PROGNOSIS_ROWS, select: { date: true, moodA1: true, @@ -107,6 +121,9 @@ export async function loadPrognosisDays( const byDay = new Map(); for (const entry of entries) { + // The rows arrive newest first, so the first one seen for a day is that + // day's last entry. + if (byDay.has(entry.date)) continue; byDay.set(entry.date, { day: entry.date, a1: entry.moodA1, @@ -185,6 +202,9 @@ export async function countQualifyingDays( }, distinct: ["date"], select: { date: true }, + // Bounded by the window either way — a year holds 366 distinct day keys — + // and the cap says so rather than trusting the window to be small. + take: 400, }); return days.length; } From 46dc31d10e6be29d9a69f781240524876a6108de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:37:21 +0200 Subject: [PATCH 10/23] fix(notifications): offer no completion action on an appointment nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An appointment reminder rides the same MEASUREMENT_REMINDER event type as a checkup — one engine, deliberately — so every channel attached its Done / Later affordance to it: the Telegram inline keyboard and the APNs category whose actions the iOS app registers. Both resolve the reminder by id, and every by-id reminder surface refuses an ENCOUNTER-origin row, so each button could only answer "not found". The producer knows the origin, so it says the dispatch carries no action and each channel drops its own affordance rather than re-deriving the rule. The reminder id still rides the metadata: it identifies the nudge for the client-managed suppression tag, and what is withheld is the offer to act. - suppressActions on the notification payload, documented where it is declared - Telegram builds no measurement keyboard for a suppressed dispatch - APNs withholds the category and strips the reminder id from the iOS payload - a negative control keeps a checkup nudge actionable --- .../__tests__/measurement-reminder.test.ts | 42 +++++++++++++++ src/lib/jobs/measurement-reminder.ts | 8 +++ .../senders/__tests__/apns.test.ts | 26 ++++++++++ .../senders/__tests__/telegram.test.ts | 17 +++++++ src/lib/notifications/senders/apns.ts | 51 +++++++++++++++++-- src/lib/notifications/senders/telegram.ts | 10 +++- src/lib/notifications/types.ts | 17 +++++++ 7 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index e2936555f..bf813ce74 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -106,6 +106,8 @@ describe("evaluateMeasurementReminderDue — window boundary", () => { interface FakeReminder { id: string; + /** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */ + origin?: string; measurementType: string | null; intervalDays: number | null; rrule: string | null; @@ -248,6 +250,46 @@ describe("runMeasurementReminderTick", () => { expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); }); + it("dispatches an appointment nudge with its completion affordance suppressed", async () => { + // `measure_done` / `measure_later` and the iOS "Erledigt" action all + // resolve the reminder by id, and every by-id surface refuses an + // ENCOUNTER-origin row. The producer knows the origin, so it is the + // producer that says the dispatch carries no action; the channels drop + // their own affordance from the flag. + const { prisma } = makePrisma({ + reminders: [ + reminder({ origin: "ENCOUNTER", measurementType: null, id: "appt-1" }), + ], + measurementMatch: null, + labMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + const payload = dispatch.mock.calls[0][0]; + expect(payload.suppressActions).toBe(true); + // The id still rides the dispatch — it identifies the nudge for the + // client-managed suppression tag. What is withheld is the offer to act. + expect(payload.metadata?.reminderId).toBe("appt-1"); + }); + + it("leaves a checkup nudge actionable", async () => { + // The negative control. Without it the assertion above would still pass + // against a producer that suppressed every reminder's affordance. + const { prisma } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined(); + }); + it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 880f0b6cf..c6a467ffa 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -338,12 +338,20 @@ export async function runMeasurementReminderTick( return { title: rendered.title, message: rendered.body }; }; + // An appointment nudge is dispatched without its Done / Later + // affordance. It rides the same event type as a checkup — one engine, + // one cron, deliberately — but every by-id reminder surface refuses an + // ENCOUNTER-origin row: satisfying one would mark an appointment + // attended from a notification, and postponing one would move a date the + // visit record still believes it owns. The channels each drop their own + // affordance from this flag rather than re-deriving the origin. const outcome = await dispatchImpl({ eventType: "MEASUREMENT_REMINDER", userId: reminder.user.id, title, message: body, renderForRecipient, + ...(reminder.origin === "ENCOUNTER" ? { suppressActions: true } : {}), metadata: { scheduledAt: now.toISOString(), reminderId: reminder.id, diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 2294c3790..7d567c230 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -674,6 +674,32 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload.replyMarkup).toBeUndefined(); }); + it("withholds the category and the reminder id when actions are suppressed", async () => { + // The category IS the action row on iOS, and the id is what the action + // would POST. An appointment nudge has neither: the by-id reminder family + // refuses an ENCOUNTER-origin row, so both are dropped and the push + // arrives as a plain alert. The event type still routes it. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEASUREMENT_REMINDER", + suppressActions: true, + metadata: { + reminderId: "rem-1", + scheduledAt: "2026-08-08T09:00:00.000Z", + }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.category).toBeUndefined(); + expect(note.payload.reminderId).toBeUndefined(); + expect(note.payload.eventType).toBe("MEASUREMENT_REMINDER"); + expect(note.payload.scheduledAt).toBe("2026-08-08T09:00:00.000Z"); + }); + it("forwards MOOD_REMINDER eventType as aps.category too", async () => { // SR-2 contract: the mood-reminder daily job emits this event-type; // the iOS app registers a `MOOD_REMINDER` category whose only diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index fdbc30d10..14455e171 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -318,6 +318,23 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => { ); }); + it("offers no keyboard on a MEASUREMENT_REMINDER whose actions are suppressed", async () => { + // An appointment nudge. The reminder id is present and the event type + // matches, so only the flag stands between the person and two buttons + // that can answer nothing but "not found". + await sendViaTelegram(config, { + eventType: "MEASUREMENT_REMINDER", + userId: "user-1", + title: "t", + message: "m", + suppressActions: true, + metadata: { reminderId: "rem-1" }, + }); + const opts = sendTelegramMessageMock.mock.calls[0][3]; + expect(opts.replyMarkup).toBeUndefined(); + expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled(); + }); + it("sends a plain MEASUREMENT_REMINDER (no keyboard, no self-clean) without a reminderId", async () => { await sendViaTelegram(config, { eventType: "MEASUREMENT_REMINDER", diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 83bbccc71..6ea4afdc7 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -592,6 +592,8 @@ export async function sendViaApns( metadata?: Record; discreet?: boolean; urgent?: boolean; + /** The dispatch offers no completion action — see `NotificationPayload`. */ + suppressActions?: boolean; recordUserId?: string; recipientUserId?: string; }, @@ -732,12 +734,21 @@ export async function sendViaApns( // an iOS action identifier or deep link that can mutate it. // Keep the ordinary event envelope so opening the notification // follows the normal authorised record-selection flow. + // `suppressActions` removes the act-on-it keys for the same + // reason it clears the category below: the affordance the app + // would build from them resolves to a refusal. ...pickIosMetadata( managedDelivery ? undefined - : payload.discreet - ? stripCycleMetadata(payload.metadata) - : payload.metadata, + : payload.suppressActions + ? stripActionMetadata( + payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, + ) + : payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, ), }, threadId: routingEvent, @@ -746,7 +757,13 @@ export async function sendViaApns( // ignores categories it doesn't know about, so adding the key // here is safe for event-types that don't have an actionable // category registered yet — they fall back to a plain alert. - category: managedDelivery ? undefined : routingEvent, + // A suppressed-action dispatch drops the category for the same + // reason a managed delivery does: the category IS the action row, + // and an appointment nudge has no action the server would accept. + category: + managedDelivery || payload.suppressActions + ? undefined + : routingEvent, // Future-proof for an iOS Notification Service Extension. iOS // ignores the flag when no extension is registered, so it's a // no-op on today's binary but unblocks NSE work without a @@ -1028,6 +1045,32 @@ function pickIosMetadata( return out; } +/** + * Metadata keys that exist so the iOS app can ACT on the notification rather + * than merely read it. Dropped when the producer says the dispatch carries no + * completion affordance. + */ +const ACTION_METADATA_KEYS = new Set(["reminderId"]); + +/** + * Strip the act-on-it keys from a payload whose actions the server will refuse. + * + * An appointment nudge rides the same `MEASUREMENT_REMINDER` event type as a + * checkup, so the iOS app would offer the registered "Erledigt" action and + * POST a completion the by-id reminder family answers 404 for. Removing the + * id is what removes the action: the handler has nothing to complete. + */ +function stripActionMetadata( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) return metadata; + const out: Record = {}; + for (const key of Object.keys(metadata)) { + if (!ACTION_METADATA_KEYS.has(key)) out[key] = metadata[key]; + } + return out; +} + /** Cycle-semantic metadata keys that must never ride a discreet payload. */ const CYCLE_METADATA_KEYS = new Set(["phase"]); diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index 8a2bf2479..205bde142 100644 --- a/src/lib/notifications/senders/telegram.ts +++ b/src/lib/notifications/senders/telegram.ts @@ -150,7 +150,15 @@ export async function sendViaTelegram( interactiveKeyboard = buildMoodKeyboard( await resolveSenderLocale(userId), ); - } else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) { + } else if ( + payload.eventType === "MEASUREMENT_REMINDER" && + reminderId && + // An appointment nudge is dispatched with the affordance suppressed: + // `measure_done` / `measure_later` both refuse an ENCOUNTER-origin row, + // so offering the pair would put two buttons in the chat that can only + // answer "not found". + !payload.suppressActions + ) { interactiveKeyboard = buildMeasurementKeyboard( reminderId, await resolveSenderLocale(userId), diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index a8db92882..ce78a0e31 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -319,6 +319,23 @@ export interface NotificationPayload { * Normal events leave `urgent` unset and behave exactly as before. */ urgent?: boolean; + /** + * The nudge carries no completion affordance. + * + * A reminder push normally ships a Done / Later pair — the Telegram inline + * keyboard and the APNs category whose actions the iOS app registers. Both + * resolve the reminder by id and both are refused for an appointment: an + * appointment reminder belongs to a visit, is managed through the visit + * routes, and every by-id reminder surface answers "not found" for one. An + * offered button the server will refuse is worse than no button, so the + * producer that knows the origin says so here and each channel drops its own + * affordance rather than each channel re-deriving the rule. + * + * The reminder's id still rides `metadata` — it identifies the dispatch for + * the client-managed suppression tag and the wide event; what this flag + * removes is the OFFER to act on it. + */ + suppressActions?: boolean; } /** From 84dae1668adc1dfc5cfc3167be2dd97c534395eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:38:27 +0200 Subject: [PATCH 11/23] docs(mood): say what the seasonal floor gates, and what it deliberately does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It gates the flag the forecast publishes, so a surface asking whether the history supports a weekday claim about THIS comparison asks one place. It does not retro-gate the weekday board the insights page has carried for releases — that board is built from the entry aggregates and has its own floors, and hiding it from accounts under ninety days would take a surface away from people already reading it to enforce a rule about a different calculation. And it does not justify building a second one. --- .../analytics/mood-prognosis/thresholds.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/analytics/mood-prognosis/thresholds.ts b/src/lib/analytics/mood-prognosis/thresholds.ts index 1d0d8e973..ebfbca2ee 100644 --- a/src/lib/analytics/mood-prognosis/thresholds.ts +++ b/src/lib/analytics/mood-prognosis/thresholds.ts @@ -54,7 +54,26 @@ export const MIN_ENTRIES_FOR_FORECAST = 30; /** Below this, a forecast is shown but labelled provisional. */ export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60; -/** Below this, no weekday and no seasonal comparison. */ +/** + * Below this, a weekday or seasonal comparison built on the forecast may not + * be shown. + * + * Two things it does NOT do, both worth stating because the omission is a + * decision rather than an oversight: + * + * * It does not retro-gate the weekday board the mood insights page has + * carried for releases. That board is built from the entry aggregates, + * not from this fit, and it has its own floors; hiding it from accounts + * under ninety days would take a surface away from people who have been + * reading it, to enforce a rule about a different calculation. + * * It does not build a second weekday board. One exists, and a second + * answer to "how do your Mondays go" is exactly the drift this milestone + * spent its effort avoiding. + * + * What it does is gate the `seasonalUnlocked` flag the forecast publishes, so + * any surface that wants to hang a weekday claim off THIS comparison — the + * clients included — asks one place whether the history supports one. + */ export const MIN_ENTRIES_FOR_SEASONAL = 90; /** From 467e83d63913f41279041baafc8e3e2e0112a140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:43:51 +0200 Subject: [PATCH 12/23] feat(encounters): one suggestion rule for every filing moment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document arriving in the vault and a lab panel being committed both ask the same question — does this belong to a visit? — and the answer has to be the same question asked once. The rule lives in one module with one exported window constant: the caller's live DONE or PLANNED visits within seven days of an anchor date, resolved into a verdict rather than a list. One candidate pre-selects, visibly, with one undo. Two or more offer a picker with nothing pre-selected: pre-selecting the first of two is what teaches a person to stop reading suggestions, and the return type has no field on that arm a caller could mistake for a choice. Zero offers nothing. Cancelled visits and no-shows are not candidates. Neither happened, so neither produced the letter or the panel being filed, and admitting them would widen the set with rows that are wrong most of the time — which pushes the honest single match into the picker branch. The verdict is served over HTTP because all three moments live in the browser. Handing the client a windowed list and letting it decide would put the rule in two places, and the copy in the browser is the one nobody re-reads. - suggest-window.ts with the window as an exported constant, not a literal - GET /api/encounters/suggest, profile domain, reviewed into the frozen literal - OpenAPI regenerated - fixtures are relative to the run instant; a fixed date slides out of a relative window and rots the test into a false green --- docs/api/openapi.yaml | 123 +++++++++ src/__tests__/sharing-surface-guard.test.ts | 8 +- src/app/api/encounters/suggest/route.ts | 59 +++++ .../__tests__/suggest-window.test.ts | 244 ++++++++++++++++++ src/lib/encounters/suggest-window.ts | 150 +++++++++++ src/lib/openapi/routes/encounters.ts | 60 +++++ src/lib/query-keys/encounters.ts | 8 + src/lib/validations/encounters.ts | 16 ++ 8 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 src/app/api/encounters/suggest/route.ts create mode 100644 src/lib/encounters/__tests__/suggest-window.test.ts create mode 100644 src/lib/encounters/suggest-window.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33fb729bd..6a3dbc7d4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9825,6 +9825,36 @@ paths: "403": *a6 "422": *a3 "429": *a4 + /api/encounters/suggest: + get: + tags: + - Records + summary: Which visit does a record dated around this day belong to? + description: "Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three + capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab + panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means + offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop + reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: + neither happened, so neither produced the record being filed. No AI provider is involved and none is required." + parameters: + - in: query + name: anchor + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: true + responses: + "200": + description: The verdict. + content: + application/json: + schema: + $ref: "#/components/schemas/SuggestEncounterEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 /api/encounters/{id}: get: tags: @@ -34923,6 +34953,99 @@ components: - data - error additionalProperties: false + SuggestEncounterEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EncounterSuggestion" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EncounterSuggestion: + oneOf: + - type: object + properties: + kind: + type: string + const: one + encounter: + $ref: "#/components/schemas/EncounterSuggestionCandidate" + required: + - kind + - encounter + additionalProperties: false + - type: object + properties: + kind: + type: string + const: many + encounters: + type: array + items: + $ref: "#/components/schemas/EncounterSuggestionCandidate" + required: + - kind + - encounters + additionalProperties: false + - type: object + properties: + kind: + type: string + const: none + required: + - kind + additionalProperties: false + description: "The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a + client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to + make impossible." + type: object + EncounterSuggestionCandidate: + type: object + properties: + id: + type: string + occurredAt: + type: string + status: + type: string + enum: + - PLANNED + - DONE + - CANCELLED + - NO_SHOW + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + practitionerName: + anyOf: + - type: string + - type: "null" + required: + - id + - occurredAt + - status + - kind + - practitionerName + additionalProperties: false + description: One visit a record might belong to, resolved far enough to render a row without a second round trip. + `practitionerName` is null when the visit names no practice, which is an absence rather than a gap. GetEncounterEnvelope: type: object properties: diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4af6c2557..7a7b2570d 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -935,6 +935,10 @@ const DELEGABLE_ROUTES: Record = { 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.", @@ -1861,8 +1865,10 @@ const ACTOR_ROUTES: Record = { * 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 visit-suggestion read, which serves the three incidental-linking + * moments from the same rule, adds one: 213 → 214. */ -const FROZEN_ENTRY_COUNT = 213; +const FROZEN_ENTRY_COUNT = 214; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/encounters/suggest/route.ts b/src/app/api/encounters/suggest/route.ts new file mode 100644 index 000000000..84914950c --- /dev/null +++ b/src/app/api/encounters/suggest/route.ts @@ -0,0 +1,59 @@ +/** + * `GET /api/encounters/suggest?anchor=` — which visit does a record dated + * `anchor` most plausibly belong to? + * + * The three capture moments that ask this question live in the browser: the + * document upload review, the lab extraction review and the manual lab form. + * The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and + * the one-pre-selects / two-offer-a-picker verdict — lives in + * `src/lib/encounters/suggest-window.ts` and is answered here so all three read + * the same answer. Handing the client a windowed list and letting it decide + * would put the rule in two places, and the copy in the browser is the one + * nobody re-reads when the rule changes. + * + * A read of the record's own visits, so it declares the same domain the visit + * list does. `userId` is narrowed from auth; the anchor is the only input and + * it is a date, not an id. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { encounterSuggestQuerySchema } from "@/lib/validations/encounters"; +import { suggestEncounterForDate } from "@/lib/encounters/suggest-window"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = encounterSuggestQuerySchema.safeParse({ + anchor: params.get("anchor") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "encounter.invalid", + }); + } + + const result = await suggestEncounterForDate(prisma, { + userId: user.id, + anchor: new Date(parsed.data.anchor), + }); + + annotate({ + action: { name: "encounter.visit.suggest", entity_type: "encounter" }, + meta: { + verdict: result.kind, + candidates: + result.kind === "many" + ? result.encounters.length + : result.kind === "one" + ? 1 + : 0, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/lib/encounters/__tests__/suggest-window.test.ts b/src/lib/encounters/__tests__/suggest-window.test.ts new file mode 100644 index 000000000..ec2ea3c61 --- /dev/null +++ b/src/lib/encounters/__tests__/suggest-window.test.ts @@ -0,0 +1,244 @@ +/** + * The suggestion rule, pinned. + * + * Two properties carry the whole feature and both are easy to break without + * noticing: + * + * - the window is ±7 days and not "recently"; + * - two candidates offer a picker with nothing pre-selected, and never the + * first one. + * + * Every fixture is RELATIVE to the moment the test runs (`now − 3 days`, never + * `2026-03-14`). A fixed-date fixture slides out of a relative window as the + * calendar moves and the test rots into a false green; this repository has + * shipped that bug before. + * + * The fake below honours the `where` it is given. A fake that ignores it would + * return every seeded row for every query, so the window assertions would pass + * against a module with no window at all — a check that cannot fail is worse + * than none. + * + * Mutation checks (both run, both seen red): + * - widen `ENCOUNTER_SUGGEST_WINDOW_DAYS` to 90 → the out-of-window cases go + * red naming the 30-day visit that started matching; + * - make the two-candidate branch return `{ kind: "one" }` with the first row + * → "offers a picker with nothing pre-selected" goes red. + */ +import { describe, it, expect } from "vitest"; + +import { + ENCOUNTER_SUGGEST_WINDOW_DAYS, + suggestEncounterForDate, +} from "../suggest-window"; +import type { Prisma } from "@/generated/prisma/client"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; + +interface Row { + id: string; + userId: string; + occurredAt: Date; + status: string; + kind: string; + deletedAt: Date | null; + practitioner: { name: string } | null; +} + +function row(over: Partial & { id: string; occurredAt: Date }): Row { + return { + userId: "u1", + status: "DONE", + kind: "ROUTINE", + deletedAt: null, + practitioner: null, + ...over, + }; +} + +/** + * An in-memory `encounter.findMany` that actually applies the filter. + * + * It evaluates every clause the module writes — the record id, the tombstone, + * the status set and both window edges — so a module that dropped one of them + * returns rows this fake would have excluded, and the assertion below fails. + */ +function fakeTx(rows: Row[]): Prisma.TransactionClient { + return { + encounter: { + findMany: async (args: { + where: { + userId: string; + deletedAt: null; + status: { in: string[] }; + occurredAt: { gte: Date; lte: Date }; + }; + take?: number; + }) => { + const { where } = args; + const matched = rows + .filter((r) => r.userId === where.userId) + .filter((r) => + where.deletedAt === null ? r.deletedAt === null : true, + ) + .filter((r) => where.status.in.includes(r.status)) + .filter( + (r) => + r.occurredAt.getTime() >= where.occurredAt.gte.getTime() && + r.occurredAt.getTime() <= where.occurredAt.lte.getTime(), + ) + .sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime()); + return matched.slice(0, args.take ?? matched.length); + }, + }, + } as unknown as Prisma.TransactionClient; +} + +const anchor = new Date(); +const ago = (days: number, extraMs = 0) => + new Date(anchor.getTime() - days * DAY_MS - extraMs); +const ahead = (days: number, extraMs = 0) => + new Date(anchor.getTime() + days * DAY_MS + extraMs); + +describe("the window", () => { + it("is seven days wide on each side", () => { + expect(ENCOUNTER_SUGGEST_WINDOW_DAYS).toBe(7); + }); + + it("admits a visit exactly seven days before the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(7) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ + kind: "one", + encounter: expect.objectContaining({ id: "e1" }), + }); + }); + + it("admits a visit exactly seven days after the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ahead(7) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ + kind: "one", + encounter: expect.objectContaining({ id: "e1" }), + }); + }); + + it("refuses a visit seven days and one minute before the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(7, MINUTE_MS) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("refuses a visit seven days and one minute after the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ahead(7, MINUTE_MS) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("refuses a visit thirty days out", async () => { + // The widened-window mutation lands here first: at ±90 days this row + // starts matching and the verdict stops being "none". + const result = await suggestEncounterForDate( + fakeTx([row({ id: "far", occurredAt: ahead(30) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); +}); + +describe("the verdict", () => { + it("offers nothing when there is no candidate", async () => { + const result = await suggestEncounterForDate(fakeTx([]), { + userId: "u1", + anchor, + }); + expect(result).toEqual({ kind: "none" }); + }); + + it("pre-selects the single candidate, resolved", async () => { + const result = await suggestEncounterForDate( + fakeTx([ + row({ + id: "e1", + occurredAt: ago(3), + kind: "SPECIALIST", + practitioner: { name: "Praxis am Park" }, + }), + ]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ + kind: "one", + encounter: { + id: "e1", + occurredAt: ago(3).toISOString(), + status: "DONE", + kind: "SPECIALIST", + practitionerName: "Praxis am Park", + }, + }); + }); + + it("offers a picker with nothing pre-selected for two candidates", async () => { + const result = await suggestEncounterForDate( + fakeTx([ + row({ id: "e1", occurredAt: ago(3) }), + row({ id: "e2", occurredAt: ago(1) }), + ]), + { userId: "u1", anchor }, + ); + // The shape is the assertion: there is no field on a "many" verdict a + // caller could read as a pre-selection. + expect(result.kind).toBe("many"); + expect(result).not.toHaveProperty("encounter"); + if (result.kind !== "many") throw new Error("unreachable"); + expect(result.encounters.map((e) => e.id)).toEqual(["e1", "e2"]); + }); +}); + +describe("what is never a candidate", () => { + it("a soft-deleted visit", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(1), deletedAt: new Date() })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("a cancelled visit, and a no-show", async () => { + // Neither happened, so neither produced the record being filed. Admitting + // them would widen the set with rows that are wrong most of the time. + const result = await suggestEncounterForDate( + fakeTx([ + row({ id: "e1", occurredAt: ago(1), status: "CANCELLED" }), + row({ id: "e2", occurredAt: ago(2), status: "NO_SHOW" }), + ]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("another account's visit", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(1), userId: "u2" })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("an unusable anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(1) })]), + { userId: "u1", anchor: new Date("not a date") }, + ); + expect(result).toEqual({ kind: "none" }); + }); +}); diff --git a/src/lib/encounters/suggest-window.ts b/src/lib/encounters/suggest-window.ts new file mode 100644 index 000000000..cc8a974b3 --- /dev/null +++ b/src/lib/encounters/suggest-window.ts @@ -0,0 +1,150 @@ +/** + * "Gehört das zu einem Termin?" — one rule, one constant, one module. + * + * Three moments ask it: a document arriving in the vault, a lab panel being + * committed from an extraction, and a lab panel typed by hand. They must ask + * the same question, so none of them owns the answer. + * + * ── The rule ──────────────────────────────────────────────────────────────── + * + * candidates = the caller's own live visits, DONE or PLANNED, whose instant + * is within ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days of the + * anchor + * + * exactly one → pre-select it, visibly, with one undo + * two or more → offer a picker with NOTHING pre-selected + * zero → offer nothing at all + * + * The middle branch is the whole point. Pre-selecting the first of two is the + * failure that teaches a person to stop reading suggestions, which costs more + * than the suggestion ever saved. This module therefore returns the SHAPE of + * the answer rather than a best guess, and a caller cannot collapse "many" into + * "one" without writing the collapse itself. + * + * ── What it is not ────────────────────────────────────────────────────────── + * + * It is not an AI call. The match is a date-window query over the person's own + * rows: it costs one index scan, cannot hallucinate, and resolves on an account + * with no provider configured — which matters, because the document assist + * itself refuses without one. + * + * It never blocks. Every caller treats the verdict as an offer; a save works + * with it ignored, dismissed, or never rendered. + * + * ── Why CANCELLED and NO_SHOW are not candidates ──────────────────────────── + * + * Both name an appointment that did not happen, so neither produced the letter + * or the panel being filed. Admitting them would widen the candidate set with + * rows that are wrong most of the time, which pushes the honest single-match + * case into the picker branch and trains exactly the distrust the two-candidate + * rule exists to prevent. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client"; + +/** + * The half-width of the match window, in days, exported so the three call + * sites and the tests read the same number. A literal at three call sites is + * three numbers that agree until one of them is edited. + */ +export const ENCOUNTER_SUGGEST_WINDOW_DAYS = 7; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * A bound on how many candidates are read. Anything above one already means + * "offer a picker", so the exact count past this cap changes no decision — and + * an unbounded read on a busy account would load a page of rows to answer a + * yes/no question. + */ +const MAX_CANDIDATES = 20; + +/** + * One candidate, resolved. Enough to render a row without a second round trip + * and without the client re-deriving a label the server already holds. + */ +export interface EncounterSuggestion { + id: string; + occurredAt: string; + status: EncounterStatus; + kind: EncounterKind; + /** The practice, when the visit names one. `null` is an absence, not a gap. */ + practitionerName: string | null; +} + +/** + * The verdict. A discriminated union rather than an array plus a boolean, + * because "one" and "many" are different offers and a caller that receives an + * array will eventually take `[0]`. + */ +export type EncounterSuggestionResult = + | { kind: "one"; encounter: EncounterSuggestion } + | { kind: "many"; encounters: EncounterSuggestion[] } + | { kind: "none" }; + +/** The statuses a visit can carry and still be the source of a filed record. */ +const CANDIDATE_STATUSES: EncounterStatus[] = ["DONE", "PLANNED"]; + +/** + * The window around an anchor instant, inclusive on both edges. + * + * Exported so a caller that needs the bounds for a different query — the + * report offer reads the same idea forward-only — states them from here rather + * than re-deriving the arithmetic. + */ +export function encounterSuggestWindow(anchor: Date): { from: Date; to: Date } { + const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS; + return { + from: new Date(anchor.getTime() - half), + to: new Date(anchor.getTime() + half), + }; +} + +/** + * Resolve the visit a record dated `anchor` most plausibly belongs to. + * + * `userId` is the caller's resolved record id and is fed straight to the + * `where`; this module never accepts one from a request body. + */ +export async function suggestEncounterForDate( + tx: Prisma.TransactionClient, + input: { userId: string; anchor: Date }, +): Promise { + if (Number.isNaN(input.anchor.getTime())) return { kind: "none" }; + + const { from, to } = encounterSuggestWindow(input.anchor); + + const rows = await tx.encounter.findMany({ + where: { + userId: input.userId, + deletedAt: null, + status: { in: CANDIDATE_STATUSES }, + occurredAt: { gte: from, lte: to }, + }, + // Nearest to the anchor would be the kinder order, but Prisma cannot sort + // by a computed distance; the caller renders a picker in chronological + // order, which is how a person reads their own week anyway. + orderBy: { occurredAt: "asc" }, + take: MAX_CANDIDATES, + select: { + id: true, + occurredAt: true, + status: true, + kind: true, + practitioner: { select: { name: true } }, + }, + }); + + const encounters: EncounterSuggestion[] = rows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt.toISOString(), + status: row.status, + kind: row.kind, + practitionerName: row.practitioner?.name ?? null, + })); + + if (encounters.length === 0) return { kind: "none" }; + if (encounters.length === 1) return { kind: "one", encounter: encounters[0] }; + return { kind: "many", encounters }; +} diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 15a860b41..d46141083 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -21,6 +21,7 @@ import { encounterCreateSchema, encounterUpdateSchema, encounterListQuerySchema, + encounterSuggestQuerySchema, encounterLinkSchema, encounterStatusEnum, encounterKindEnum, @@ -56,6 +57,12 @@ encounterListQuerySchema.meta({ "Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).", }); +encounterSuggestQuerySchema.meta({ + id: "SuggestEncounterQuery", + description: + "The date to resolve around, as an ISO-8601 instant with an offset. A document uses `reportDate ?? documentDate`; a lab panel uses `takenAt`.", +}); + encounterLinkSchema.meta({ id: "EncounterLinkRequest", description: @@ -172,6 +179,35 @@ const encounterList = z "Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.", }); +const suggestionCandidate = z + .object({ + id: z.string(), + occurredAt: z.string(), + status: encounterStatusEnum, + kind: encounterKindEnum, + practitionerName: z.string().nullable(), + }) + .meta({ + id: "EncounterSuggestionCandidate", + description: + "One visit a record might belong to, resolved far enough to render a row without a second round trip. `practitionerName` is null when the visit names no practice, which is an absence rather than a gap.", + }); + +const encounterSuggestion = z + .discriminatedUnion("kind", [ + z.object({ kind: z.literal("one"), encounter: suggestionCandidate }), + z.object({ + kind: z.literal("many"), + encounters: z.array(suggestionCandidate), + }), + z.object({ kind: z.literal("none") }), + ]) + .meta({ + id: "EncounterSuggestion", + description: + "The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to make impossible.", + }); + const encounterNotFound = { "404": { description: "Visit not found (or owned by another user).", @@ -232,6 +268,30 @@ export const encounterPaths: NonNullable = { }, }, }, + "/api/encounters/suggest": { + get: { + tags: ["Records"], + summary: "Which visit does a record dated around this day belong to?", + description: + "Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: neither happened, so neither produced the record being filed. No AI provider is involved and none is required.", + requestParams: { query: encounterSuggestQuerySchema }, + responses: { + ...recordRefusal(), + "200": { + description: "The verdict.", + content: { + "application/json": { + schema: dataEnvelope( + encounterSuggestion, + "SuggestEncounterEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/encounters/{id}": { get: { tags: ["Records"], diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index d59d9d591..a5ea82533 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -34,6 +34,14 @@ export const encounterKeys = { encounterList: (from: string | null, to: string | null, status?: string) => ["encounters", "list", from, to, status ?? null] as const, encounter: (id: string) => ["encounters", "detail", id] as const, + /** + * The "which visit does this belong to" verdict for one anchor date. The + * anchor is part of the key because it IS the question: a document dated the + * 3rd and one dated the 20th get different answers, and one overwriting the + * other would pre-select the wrong visit on the second review step. + */ + encounterSuggestion: (anchor: string) => + ["encounters", "suggest", anchor] as const, /** * The address book. Under its own root rather than nested beneath * `["encounters"]`: a practitioner outlives the visits that name it, and a diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 2217b53be..ce6a1d54c 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -148,6 +148,22 @@ export const encounterListQuerySchema = z export type EncounterListQuery = z.infer; +/** + * The suggestion query. + * + * One input, and it is a date rather than an id: the answer is derived from the + * caller's own visits and the anchor only says where to look. Bounded by the + * same instant rule the visit itself carries, so an anchor a client mis-parsed + * into the year 12000 is a 422 rather than a full-table scan. + */ +export const encounterSuggestQuerySchema = z + .object({ + anchor: visitInstant, + }) + .strict(); + +export type EncounterSuggestQuery = z.infer; + /* ── links ────────────────────────────────────────────────────────── */ export const encounterLinkTargetEnum = z.enum([ From ed1438230c8ee161b8b6c0935a155219c4650b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:21:43 +0200 Subject: [PATCH 13/23] feat(encounters): visits and the address book become reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /checkups gains a second section rather than a nav entry. The page already owns the right sentence — when do I have to do what, where — and is deliberately not module gated, which is the same reason a visit carries no module key of its own: what a visit may point AT follows the modules that own those things, so the gate sits on the three link pickers and blanks the block rather than rendering an empty one. A visit saves with a date and nothing else. No required practitioner, no required kind, no required link, and the only thing that can disable Save is a date that will not parse. The status is derived from the date on a new visit and only becomes a field on an existing one, because somebody booking an appointment should not also have to tell the app that it is in the future. The address book is record content, not configuration, so it sits under /checkups rather than in Settings — and it inherits that page's route-family classification with it. Deleting an entry says in one sentence that the old visits stay, because a person deleting a doctor has every reason to fear otherwise. Two defects closed on the way: - /checkups carried sharedRecord: true and appeared in neither route family, so a scoped grant could not present it at all. It was invisible while a whole-record grant admitted every path; the visits section makes it load bearing, and the page is now listed under both domains whose content it shows. Deduplicated, or a grant holding both would offer the page twice. - the address-book search read the name only, so looking a practice up by the name on the door returned nothing and read as "not in the book". The practitioner search description in the OpenAPI module moved with it. --- messages/de.json | 82 ++++- messages/en.json | 82 ++++- messages/es.json | 82 ++++- messages/fr.json | 82 ++++- messages/it.json | 82 ++++- messages/pl.json | 82 ++++- .../destructive-control-guard.test.ts | 20 ++ .../shared-record-navigation.test.tsx | 36 ++- src/app/api/practitioners/route.ts | 21 +- src/app/checkups/page.tsx | 45 ++- src/app/checkups/practitioners/page.tsx | 28 ++ .../__tests__/encounter-form.test.tsx | 184 ++++++++++++ .../__tests__/visits-section.test.tsx | 127 ++++++++ src/components/encounters/encounter-form.tsx | 283 ++++++++++++++++++ src/components/encounters/encounter-labels.ts | 74 +++++ .../encounters/encounter-link-pickers.tsx | 248 +++++++++++++++ src/components/encounters/encounter-sheet.tsx | 207 +++++++++++++ .../encounters/practitioner-combobox.tsx | 177 +++++++++++ src/components/encounters/visit-card.tsx | 127 ++++++++ src/components/encounters/visits-section.tsx | 147 +++++++++ .../practitioners/practitioner-list.tsx | 228 ++++++++++++++ .../practitioners/practitioner-sheet.tsx | 205 +++++++++++++ src/hooks/use-encounters.ts | 117 ++++++++ src/hooks/use-practitioners.ts | 72 +++++ src/lib/navigation/shared-record.ts | 21 +- src/lib/openapi/routes/encounters.ts | 2 +- src/lib/query-keys/encounters.ts | 9 + 27 files changed, 2851 insertions(+), 19 deletions(-) create mode 100644 src/app/checkups/practitioners/page.tsx create mode 100644 src/components/encounters/__tests__/encounter-form.test.tsx create mode 100644 src/components/encounters/__tests__/visits-section.test.tsx create mode 100644 src/components/encounters/encounter-form.tsx create mode 100644 src/components/encounters/encounter-labels.ts create mode 100644 src/components/encounters/encounter-link-pickers.tsx create mode 100644 src/components/encounters/encounter-sheet.tsx create mode 100644 src/components/encounters/practitioner-combobox.tsx create mode 100644 src/components/encounters/visit-card.tsx create mode 100644 src/components/encounters/visits-section.tsx create mode 100644 src/components/practitioners/practitioner-list.tsx create mode 100644 src/components/practitioners/practitioner-sheet.tsx create mode 100644 src/hooks/use-encounters.ts create mode 100644 src/hooks/use-practitioners.ts diff --git a/messages/de.json b/messages/de.json index 22e0b9015..8d03ea71b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10073,6 +10073,86 @@ "hospital": "Krankenhaus", "therapy": "Therapietermin", "other": "Arzttermin" - } + }, + "sectionTitle": "Termine", + "sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.", + "add": "Termin", + "loadError": "Termine konnten nicht geladen werden.", + "emptyTitle": "Noch kein Termin erfasst", + "emptyDescription": "Ein Datum genügt. Alles andere ist freiwillig.", + "upcomingHeading": "Anstehend", + "pastHeading": "Vergangen", + "createTitle": "Termin erfassen", + "editTitle": "Termin bearbeiten", + "formDescription": "Nur das Datum ist Pflicht.", + "created": "Termin gespeichert.", + "updated": "Termin aktualisiert.", + "saveFailed": "Termin konnte nicht gespeichert werden.", + "checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen — dafür reicht deine Freigabe nicht.", + "status": { + "planned": "Geplant", + "done": "Stattgefunden", + "cancelled": "Abgesagt", + "noShow": "Nicht wahrgenommen" + }, + "card": { + "documentCount": "verknüpfte Dokumente", + "labCount": "verknüpfte Laborwerte", + "conditionCount": "verknüpfte Erkrankungen" + }, + "form": { + "date": "Datum und Uhrzeit", + "dateDerivesStatusHint": "Ein Datum in der Zukunft ist ein gebuchter Termin.", + "status": "Status", + "practitioner": "Praxis oder Ärztin", + "practitionerHint": "Optional — du kannst den Namen später nachtragen.", + "practitionerNone": "Keine Angabe", + "practitionerSearch": "Name oder Praxis suchen", + "practitionerNoMatch": "Kein Eintrag gefunden.", + "practitionerAdd": "Neu anlegen", + "practitionerClear": "Auswahl entfernen", + "kind": "Art", + "reason": "Anlass", + "outcome": "Ergebnis", + "outcomeReveal": "Ergebnis eintragen", + "linkDocuments": "Dokumente", + "linkLabResults": "Laborwerte", + "linkConditions": "Erkrankungen", + "linkNothingToOffer": "Nichts zum Verknüpfen vorhanden." + }, + "deleteTitle": "Termin löschen?", + "deleteDescription": "Der Eintrag wird entfernt. Ist der Termin gebucht, entfällt auch die Erinnerung dazu.", + "deleteFailed": "Termin konnte nicht gelöscht werden." + }, + "checkups": { + "viewSwitchLabel": "Vorsorge oder Termine anzeigen", + "tabVorsorge": "Vorsorge", + "tabVisits": "Termine" + }, + "practitioners": { + "title": "Praxen und Ärzte", + "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.", + "linkLabel": "Adressbuch", + "add": "Eintrag", + "searchPlaceholder": "Name oder Praxis suchen", + "loadError": "Adressbuch konnte nicht geladen werden.", + "emptyTitle": "Noch kein Eintrag", + "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.", + "noMatchTitle": "Kein Treffer", + "noMatchDescription": "Zu dieser Suche gibt es keinen Eintrag.", + "createTitle": "Eintrag anlegen", + "editTitle": "Eintrag bearbeiten", + "formDescription": "Nur der Name ist Pflicht.", + "name": "Name", + "specialty": "Fachrichtung", + "practice": "Praxis", + "location": "Adresse", + "phone": "Telefon", + "note": "Notiz", + "noteHint": "Verschlüsselt gespeichert.", + "saveFailed": "Eintrag konnte nicht gespeichert werden.", + "deleteTitle": "Eintrag löschen?", + "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", + "deleteFailed": "Eintrag konnte nicht gelöscht werden." } } diff --git a/messages/en.json b/messages/en.json index afacc42d2..c62cff149 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10073,6 +10073,86 @@ "hospital": "Hospital", "therapy": "Therapy session", "other": "Appointment" - } + }, + "sectionTitle": "Visits", + "sectionDescription": "Doctor's visits and booked appointments in one place.", + "add": "Visit", + "loadError": "Visits could not be loaded.", + "emptyTitle": "No visit recorded yet", + "emptyDescription": "A date is enough. Everything else is optional.", + "upcomingHeading": "Upcoming", + "pastHeading": "Past", + "createTitle": "Record a visit", + "editTitle": "Edit visit", + "formDescription": "Only the date is required.", + "created": "Visit saved.", + "updated": "Visit updated.", + "saveFailed": "The visit could not be saved.", + "checkupNotClosed": "Visit saved. The checkup stays open — your access does not cover that.", + "status": { + "planned": "Planned", + "done": "Attended", + "cancelled": "Cancelled", + "noShow": "Missed" + }, + "card": { + "documentCount": "linked documents", + "labCount": "linked lab results", + "conditionCount": "linked conditions" + }, + "form": { + "date": "Date and time", + "dateDerivesStatusHint": "A date in the future is a booked appointment.", + "status": "Status", + "practitioner": "Practice or doctor", + "practitionerHint": "Optional — you can add the name later.", + "practitionerNone": "Not specified", + "practitionerSearch": "Search name or practice", + "practitionerNoMatch": "No entry found.", + "practitionerAdd": "Add new", + "practitionerClear": "Clear selection", + "kind": "Kind", + "reason": "Reason", + "outcome": "Outcome", + "outcomeReveal": "Add an outcome", + "linkDocuments": "Documents", + "linkLabResults": "Lab results", + "linkConditions": "Conditions", + "linkNothingToOffer": "Nothing here to link yet." + }, + "deleteTitle": "Delete this visit?", + "deleteDescription": "The entry is removed. If the visit is booked, its reminder stops with it.", + "deleteFailed": "The visit could not be deleted." + }, + "checkups": { + "viewSwitchLabel": "Show checkups or visits", + "tabVorsorge": "Checkups", + "tabVisits": "Visits" + }, + "practitioners": { + "title": "Practices and doctors", + "description": "Your own address book — type a practice once, pick it everywhere.", + "linkLabel": "Address book", + "add": "Entry", + "searchPlaceholder": "Search name or practice", + "loadError": "The address book could not be loaded.", + "emptyTitle": "No entry yet", + "emptyDescription": "Add a practice so you can pick it when you record a visit.", + "noMatchTitle": "No match", + "noMatchDescription": "No entry matches this search.", + "createTitle": "Add an entry", + "editTitle": "Edit entry", + "formDescription": "Only the name is required.", + "name": "Name", + "specialty": "Specialty", + "practice": "Practice", + "location": "Address", + "phone": "Phone", + "note": "Note", + "noteHint": "Stored encrypted.", + "saveFailed": "The entry could not be saved.", + "deleteTitle": "Delete this entry?", + "deleteDescription": "Your existing visits stay, and they keep this name.", + "deleteFailed": "The entry could not be deleted." } } diff --git a/messages/es.json b/messages/es.json index 7cec1ba45..a7c38c795 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10073,6 +10073,86 @@ "hospital": "Hospital", "therapy": "Sesión de terapia", "other": "Cita médica" - } + }, + "sectionTitle": "Citas", + "sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.", + "add": "Cita", + "loadError": "No se pudieron cargar las citas.", + "emptyTitle": "Todavía no hay ninguna cita", + "emptyDescription": "Basta con una fecha. Todo lo demás es opcional.", + "upcomingHeading": "Próximas", + "pastHeading": "Pasadas", + "createTitle": "Registrar una cita", + "editTitle": "Editar la cita", + "formDescription": "Solo la fecha es obligatoria.", + "created": "Cita guardada.", + "updated": "Cita actualizada.", + "saveFailed": "No se pudo guardar la cita.", + "checkupNotClosed": "Cita guardada. La revisión sigue pendiente: tu acceso no lo permite.", + "status": { + "planned": "Prevista", + "done": "Realizada", + "cancelled": "Cancelada", + "noShow": "No acudida" + }, + "card": { + "documentCount": "documentos vinculados", + "labCount": "resultados de laboratorio vinculados", + "conditionCount": "enfermedades vinculadas" + }, + "form": { + "date": "Fecha y hora", + "dateDerivesStatusHint": "Una fecha futura es una cita reservada.", + "status": "Estado", + "practitioner": "Consulta o médico", + "practitionerHint": "Opcional: puedes añadir el nombre más tarde.", + "practitionerNone": "Sin indicar", + "practitionerSearch": "Buscar nombre o consulta", + "practitionerNoMatch": "No se encontró ninguna entrada.", + "practitionerAdd": "Añadir nueva", + "practitionerClear": "Quitar la selección", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Resultado", + "outcomeReveal": "Añadir un resultado", + "linkDocuments": "Documentos", + "linkLabResults": "Resultados de laboratorio", + "linkConditions": "Enfermedades", + "linkNothingToOffer": "Todavía no hay nada que vincular." + }, + "deleteTitle": "¿Eliminar esta cita?", + "deleteDescription": "Se elimina la entrada. Si la cita está reservada, su recordatorio también desaparece.", + "deleteFailed": "No se pudo eliminar la cita." + }, + "checkups": { + "viewSwitchLabel": "Mostrar revisiones o citas", + "tabVorsorge": "Revisiones", + "tabVisits": "Citas" + }, + "practitioners": { + "title": "Consultas y médicos", + "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.", + "linkLabel": "Agenda", + "add": "Entrada", + "searchPlaceholder": "Buscar nombre o consulta", + "loadError": "No se pudo cargar la agenda.", + "emptyTitle": "Todavía no hay entradas", + "emptyDescription": "Añade una consulta para poder elegirla al registrar una cita.", + "noMatchTitle": "Sin coincidencias", + "noMatchDescription": "Ninguna entrada coincide con esta búsqueda.", + "createTitle": "Añadir una entrada", + "editTitle": "Editar la entrada", + "formDescription": "Solo el nombre es obligatorio.", + "name": "Nombre", + "specialty": "Especialidad", + "practice": "Consulta", + "location": "Dirección", + "phone": "Teléfono", + "note": "Nota", + "noteHint": "Se guarda cifrada.", + "saveFailed": "No se pudo guardar la entrada.", + "deleteTitle": "¿Eliminar esta entrada?", + "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", + "deleteFailed": "No se pudo eliminar la entrada." } } diff --git a/messages/fr.json b/messages/fr.json index d91f2cf3d..3b69dd66d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10073,6 +10073,86 @@ "hospital": "Hôpital", "therapy": "Séance de thérapie", "other": "Rendez-vous médical" - } + }, + "sectionTitle": "Rendez-vous", + "sectionDescription": "Consultations et rendez-vous pris, au même endroit.", + "add": "Rendez-vous", + "loadError": "Impossible de charger les rendez-vous.", + "emptyTitle": "Aucun rendez-vous enregistré", + "emptyDescription": "Une date suffit. Le reste est facultatif.", + "upcomingHeading": "À venir", + "pastHeading": "Passés", + "createTitle": "Enregistrer un rendez-vous", + "editTitle": "Modifier le rendez-vous", + "formDescription": "Seule la date est obligatoire.", + "created": "Rendez-vous enregistré.", + "updated": "Rendez-vous mis à jour.", + "saveFailed": "Le rendez-vous n'a pas pu être enregistré.", + "checkupNotClosed": "Rendez-vous enregistré. Le dépistage reste ouvert : votre accès ne le couvre pas.", + "status": { + "planned": "Prévu", + "done": "Effectué", + "cancelled": "Annulé", + "noShow": "Non honoré" + }, + "card": { + "documentCount": "documents liés", + "labCount": "résultats de laboratoire liés", + "conditionCount": "maladies liées" + }, + "form": { + "date": "Date et heure", + "dateDerivesStatusHint": "Une date à venir correspond à un rendez-vous pris.", + "status": "Statut", + "practitioner": "Cabinet ou médecin", + "practitionerHint": "Facultatif : vous pourrez ajouter le nom plus tard.", + "practitionerNone": "Non précisé", + "practitionerSearch": "Rechercher un nom ou un cabinet", + "practitionerNoMatch": "Aucune entrée trouvée.", + "practitionerAdd": "Créer une entrée", + "practitionerClear": "Retirer la sélection", + "kind": "Type", + "reason": "Motif", + "outcome": "Résultat", + "outcomeReveal": "Ajouter un résultat", + "linkDocuments": "Documents", + "linkLabResults": "Résultats de laboratoire", + "linkConditions": "Maladies", + "linkNothingToOffer": "Rien à lier pour le moment." + }, + "deleteTitle": "Supprimer ce rendez-vous ?", + "deleteDescription": "L'entrée est supprimée. Si le rendez-vous est pris, son rappel disparaît avec elle.", + "deleteFailed": "Le rendez-vous n'a pas pu être supprimé." + }, + "checkups": { + "viewSwitchLabel": "Afficher les dépistages ou les rendez-vous", + "tabVorsorge": "Dépistages", + "tabVisits": "Rendez-vous" + }, + "practitioners": { + "title": "Cabinets et médecins", + "description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.", + "linkLabel": "Carnet d'adresses", + "add": "Entrée", + "searchPlaceholder": "Rechercher un nom ou un cabinet", + "loadError": "Impossible de charger le carnet d'adresses.", + "emptyTitle": "Aucune entrée", + "emptyDescription": "Ajoutez un cabinet pour pouvoir le choisir lors d'un rendez-vous.", + "noMatchTitle": "Aucun résultat", + "noMatchDescription": "Aucune entrée ne correspond à cette recherche.", + "createTitle": "Ajouter une entrée", + "editTitle": "Modifier l'entrée", + "formDescription": "Seul le nom est obligatoire.", + "name": "Nom", + "specialty": "Spécialité", + "practice": "Cabinet", + "location": "Adresse", + "phone": "Téléphone", + "note": "Note", + "noteHint": "Enregistrée chiffrée.", + "saveFailed": "L'entrée n'a pas pu être enregistrée.", + "deleteTitle": "Supprimer cette entrée ?", + "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", + "deleteFailed": "L'entrée n'a pas pu être supprimée." } } diff --git a/messages/it.json b/messages/it.json index 60bfd0bf4..c79d33ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10073,6 +10073,86 @@ "hospital": "Ospedale", "therapy": "Seduta di terapia", "other": "Appuntamento medico" - } + }, + "sectionTitle": "Appuntamenti", + "sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.", + "add": "Appuntamento", + "loadError": "Impossibile caricare gli appuntamenti.", + "emptyTitle": "Nessun appuntamento registrato", + "emptyDescription": "Basta una data. Tutto il resto è facoltativo.", + "upcomingHeading": "In arrivo", + "pastHeading": "Passati", + "createTitle": "Registra un appuntamento", + "editTitle": "Modifica appuntamento", + "formDescription": "Solo la data è obbligatoria.", + "created": "Appuntamento salvato.", + "updated": "Appuntamento aggiornato.", + "saveFailed": "Non è stato possibile salvare l'appuntamento.", + "checkupNotClosed": "Appuntamento salvato. Il controllo resta aperto: il tuo accesso non lo comprende.", + "status": { + "planned": "Previsto", + "done": "Effettuato", + "cancelled": "Annullato", + "noShow": "Non presentato" + }, + "card": { + "documentCount": "documenti collegati", + "labCount": "esami di laboratorio collegati", + "conditionCount": "malattie collegate" + }, + "form": { + "date": "Data e ora", + "dateDerivesStatusHint": "Una data futura è un appuntamento fissato.", + "status": "Stato", + "practitioner": "Studio o medico", + "practitionerHint": "Facoltativo: puoi aggiungere il nome più tardi.", + "practitionerNone": "Non indicato", + "practitionerSearch": "Cerca nome o studio", + "practitionerNoMatch": "Nessuna voce trovata.", + "practitionerAdd": "Aggiungi nuova", + "practitionerClear": "Rimuovi la selezione", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Esito", + "outcomeReveal": "Aggiungi un esito", + "linkDocuments": "Documenti", + "linkLabResults": "Esami di laboratorio", + "linkConditions": "Malattie", + "linkNothingToOffer": "Per ora non c'è nulla da collegare." + }, + "deleteTitle": "Eliminare questo appuntamento?", + "deleteDescription": "La voce viene rimossa. Se l'appuntamento è fissato, anche il promemoria sparisce.", + "deleteFailed": "Non è stato possibile eliminare l'appuntamento." + }, + "checkups": { + "viewSwitchLabel": "Mostra controlli o appuntamenti", + "tabVorsorge": "Controlli", + "tabVisits": "Appuntamenti" + }, + "practitioners": { + "title": "Studi e medici", + "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.", + "linkLabel": "Rubrica", + "add": "Voce", + "searchPlaceholder": "Cerca nome o studio", + "loadError": "Impossibile caricare la rubrica.", + "emptyTitle": "Nessuna voce", + "emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.", + "noMatchTitle": "Nessuna corrispondenza", + "noMatchDescription": "Nessuna voce corrisponde a questa ricerca.", + "createTitle": "Aggiungi una voce", + "editTitle": "Modifica la voce", + "formDescription": "Solo il nome è obbligatorio.", + "name": "Nome", + "specialty": "Specializzazione", + "practice": "Studio", + "location": "Indirizzo", + "phone": "Telefono", + "note": "Nota", + "noteHint": "Salvata cifrata.", + "saveFailed": "Non è stato possibile salvare la voce.", + "deleteTitle": "Eliminare questa voce?", + "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", + "deleteFailed": "Non è stato possibile eliminare la voce." } } diff --git a/messages/pl.json b/messages/pl.json index cd1f4c71e..6ac56d55c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10073,6 +10073,86 @@ "hospital": "Szpital", "therapy": "Sesja terapeutyczna", "other": "Wizyta lekarska" - } + }, + "sectionTitle": "Wizyty", + "sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.", + "add": "Wizyta", + "loadError": "Nie udało się wczytać wizyt.", + "emptyTitle": "Brak zapisanych wizyt", + "emptyDescription": "Wystarczy data. Reszta jest opcjonalna.", + "upcomingHeading": "Nadchodzące", + "pastHeading": "Minione", + "createTitle": "Zapisz wizytę", + "editTitle": "Edytuj wizytę", + "formDescription": "Wymagana jest tylko data.", + "created": "Wizyta zapisana.", + "updated": "Wizyta zaktualizowana.", + "saveFailed": "Nie udało się zapisać wizyty.", + "checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte — twój dostęp tego nie obejmuje.", + "status": { + "planned": "Zaplanowana", + "done": "Odbyta", + "cancelled": "Odwołana", + "noShow": "Niewykorzystana" + }, + "card": { + "documentCount": "powiązane dokumenty", + "labCount": "powiązane wyniki badań", + "conditionCount": "powiązane choroby" + }, + "form": { + "date": "Data i godzina", + "dateDerivesStatusHint": "Data w przyszłości oznacza umówiony termin.", + "status": "Status", + "practitioner": "Gabinet lub lekarz", + "practitionerHint": "Opcjonalne — nazwę możesz dodać później.", + "practitionerNone": "Nie podano", + "practitionerSearch": "Szukaj nazwiska lub gabinetu", + "practitionerNoMatch": "Nie znaleziono wpisu.", + "practitionerAdd": "Dodaj nowy", + "practitionerClear": "Usuń wybór", + "kind": "Rodzaj", + "reason": "Powód", + "outcome": "Wynik", + "outcomeReveal": "Dodaj wynik", + "linkDocuments": "Dokumenty", + "linkLabResults": "Wyniki badań", + "linkConditions": "Choroby", + "linkNothingToOffer": "Na razie nie ma nic do powiązania." + }, + "deleteTitle": "Usunąć tę wizytę?", + "deleteDescription": "Wpis zostanie usunięty. Jeśli wizyta jest umówiona, przypomnienie o niej też zniknie.", + "deleteFailed": "Nie udało się usunąć wizyty." + }, + "checkups": { + "viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty", + "tabVorsorge": "Profilaktyka", + "tabVisits": "Wizyty" + }, + "practitioners": { + "title": "Gabinety i lekarze", + "description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.", + "linkLabel": "Książka adresowa", + "add": "Wpis", + "searchPlaceholder": "Szukaj nazwiska lub gabinetu", + "loadError": "Nie udało się wczytać książki adresowej.", + "emptyTitle": "Brak wpisów", + "emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.", + "noMatchTitle": "Brak wyników", + "noMatchDescription": "Żaden wpis nie pasuje do tego wyszukiwania.", + "createTitle": "Dodaj wpis", + "editTitle": "Edytuj wpis", + "formDescription": "Wymagana jest tylko nazwa.", + "name": "Nazwa", + "specialty": "Specjalizacja", + "practice": "Gabinet", + "location": "Adres", + "phone": "Telefon", + "note": "Notatka", + "noteHint": "Zapisywana w postaci zaszyfrowanej.", + "saveFailed": "Nie udało się zapisać wpisu.", + "deleteTitle": "Usunąć ten wpis?", + "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", + "deleteFailed": "Nie udało się usunąć wpisu." } } diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 8d798ce6a..413863fac 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -388,6 +388,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", diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index 3413dc1a3..65cf82ca5 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -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, diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index 04a495b5a..bac66da4b 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null, + // Both plaintext columns, because a person looks a practice up by + // whichever of the two they remember: the doctor's name on the referral + // or the practice name on the door. Searching only the name made the + // second attempt return nothing and read as "not in the address book". ...(parsed.data.q - ? { name: { contains: parsed.data.q, mode: "insensitive" as const } } + ? { + OR: [ + { + name: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + { + practice: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + ], + } : {}), }, orderBy: { name: "asc" }, diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 2105fb54b..4b2fbf1c5 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; @@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; -import { queryKeys } from "@/lib/query-keys"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; /** * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated @@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys"; * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood, * measurements): a pull invalidates the measurement-reminders read so the * list + server-computed next-due repaint. + * + * The page now holds two things, split by a segmented control rather than by a + * second nav entry: the preventive-care list, and the visits. They answer the + * same question from two directions — what do I still have to arrange, and what + * did I actually attend — and the page's own sentence already covers both. A + * visit carries no module gate for the same reason this page never had one. + * + * The pull invalidates both reads. Refreshing one and leaving the other stale + * is how a page ends up showing a visit that closed a checkup still listed as + * due. */ export default function CheckupsPage() { const { isAuthenticated, isLoading } = useAuth(); const mounted = useMounted(); const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [view, setView] = useState("vorsorge"); const refresh = useCallback( () => - queryClient.invalidateQueries({ - queryKey: queryKeys.measurementReminders(), - }), + invalidateKeys(queryClient, [ + queryKeys.measurementReminders(), + queryKeys.encounters(), + ]), [queryClient], ); const pull = usePullToRefresh({ onRefresh: refresh }); @@ -41,7 +57,24 @@ export default function CheckupsPage() { return (
- + + + + {t("checkups.tabVorsorge")} + + + {t("checkups.tabVisits")} + + + + + + + {/* Mounted only on the active tab, so the visits read does not fire + for a person who never opens it. */} + + +
); } diff --git a/src/app/checkups/practitioners/page.tsx b/src/app/checkups/practitioners/page.tsx new file mode 100644 index 000000000..1a4d27e7f --- /dev/null +++ b/src/app/checkups/practitioners/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { PractitionerList } from "@/components/practitioners/practitioner-list"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; + +/** + * The address book, one level under the checkups page the visits live on. + * + * Deliberately not in Settings. The Settings IA rule is that a settings surface + * configures behaviour; a list of one's own doctors configures nothing and is + * record content like every other clinical list. Sitting under `/checkups` + * also means it inherits that page's route-family classification, so a + * `profile`-scoped delegate reaches it exactly as they reach the visits. + */ +export default function PractitionersPage() { + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + + if (!mounted || isLoading) return ; + + return ( +
+ +
+ ); +} diff --git a/src/components/encounters/__tests__/encounter-form.test.tsx b/src/components/encounters/__tests__/encounter-form.test.tsx new file mode 100644 index 000000000..ee7b9a04b --- /dev/null +++ b/src/components/encounters/__tests__/encounter-form.test.tsx @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The one product constraint this whole feature exists to hold: **a visit + * saves with a date and nothing else.** + * + * Three properties, each of which has an obvious way to get broken by a later + * "improvement": + * + * - no field but the date can block Save; + * - the status is DERIVED from the date on a new visit, never asked; + * - a link picker whose module is off is BLANK, not empty — an empty picker + * for a switched-off module advertises a feature that is not there. + */ + +let modules: Record = {}; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canWrite: true }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + EncounterForm, + derivedStatus, + draftInstant, + draftToBody, + emptyDraft, +} from "../encounter-form"; + +/** + * The form body, rendered directly rather than through its sheet: the sheet is + * a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a + * test that went through it would assert against an empty string and pass for + * the wrong reason. + */ +function renderForm(over?: { + modules?: Record; + isEdit?: boolean; +}) { + modules = over?.modules ?? {}; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + {}} + isEdit={over?.isEdit ?? false} + /> + + , + ); +} + +describe("nothing but the date is required", () => { + it("admits a draft carrying only a date", () => { + // `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft + // with no practitioner, no reason, no outcome and no link is saveable. + const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" }); + expect(draft.practitioner).toBeNull(); + expect(draft.reason).toBe(""); + expect(draft.documentIds).toEqual([]); + expect(draftInstant(draft.occurredAt)).not.toBeNull(); + }); + + it("refuses only when the date itself cannot be read", () => { + expect(draftInstant("not a date")).toBeNull(); + }); + + it("marks the date field required and nothing else", () => { + const html = renderForm(); + const required = html.match(/aria-required="true"/g) ?? []; + expect(required).toHaveLength(1); + expect(html).toContain('data-testid="encounter-occurred-at"'); + }); + + it("turns a date-only draft into a complete write body", () => { + const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), { + isEdit: false, + }); + expect(body).not.toBeNull(); + expect(body?.practitionerId).toBeNull(); + expect(body?.reason).toBeNull(); + expect(body?.outcome).toBeNull(); + expect(body?.documentIds).toEqual([]); + }); + + it("refuses to build a body from a date that does not parse", () => { + expect( + draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }), + ).toBeNull(); + }); +}); + +describe("the status follows the date on a new visit", () => { + const now = Date.parse("2026-08-08T12:00:00.000Z"); + + it("reads a future instant as a booked appointment", () => { + expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED"); + }); + + it("reads a past instant as a visit that happened", () => { + expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE"); + }); + + it("never asks for the status on a new visit", () => { + // The picker is what "asked" would look like; its absence is the property. + expect(renderForm()).not.toContain('id="encounter-status"'); + }); + + it("carries the derived status into the body rather than the draft default", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }), + { isEdit: false }, + ); + expect(body?.status).toBe("PLANNED"); + }); + + it("uses the draft's own status on an edit, where it is editable", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }), + { isEdit: true }, + ); + expect(body?.status).toBe("CANCELLED"); + }); +}); + +describe("the link pickers follow the modules that own their targets", () => { + it("renders no link block at all when all three modules are off", () => { + const html = renderForm({ modules: {} }); + expect(html).not.toContain('data-slot="encounter-link-pickers"'); + expect(html).not.toContain('data-slot="encounter-link-documents"'); + expect(html).not.toContain('data-slot="encounter-link-labs"'); + expect(html).not.toContain('data-slot="encounter-link-conditions"'); + }); + + it("blanks the block of a disabled module while keeping its neighbours", () => { + const html = renderForm({ + modules: { inboundDocuments: true, labs: false, illness: false }, + }); + expect(html).toContain('data-slot="encounter-link-documents"'); + // Not "renders an empty list" — absent. + expect(html).not.toContain('data-slot="encounter-link-labs"'); + expect(html).not.toContain('data-slot="encounter-link-conditions"'); + }); + + it("offers all three when all three modules are on", () => { + const html = renderForm({ + modules: { inboundDocuments: true, labs: true, illness: true }, + }); + expect(html).toContain('data-slot="encounter-link-documents"'); + expect(html).toContain('data-slot="encounter-link-labs"'); + expect(html).toContain('data-slot="encounter-link-conditions"'); + // The positive control: without it the three assertions above would pass + // against a component that never renders any block. + expect(html).toContain('data-slot="encounter-link-pickers"'); + }); +}); diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx new file mode 100644 index 000000000..a7da7ed91 --- /dev/null +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The visits surface's designed states, pinned by single-pass static renders. + * + * - an account with no visits paints the empty state and one action, not an + * error and not a teaching wall; + * - upcoming sorts above past, in the two lists the SERVER split — the + * client renders the order it was given rather than re-deriving one; + * - a soft-deleted visit never arrives, so it cannot be rendered. That is a + * server property and the API test owns it; what this file pins is that + * the section renders exactly what the response carried, with nothing + * filtered or re-sorted on this side that could disagree with it. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: {} }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canWrite: true }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + // Never resolves inside a synchronous render, so an unseeded query stays + // pending and cannot accidentally satisfy an assertion. + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO } from "@/lib/encounters/dto"; +import { VisitsSection } from "../visits-section"; + +function visit(over: Partial & { id: string }): EncounterDTO { + return { + occurredAt: "2026-08-01T09:00:00.000Z", + status: "DONE", + kind: "ROUTINE", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", + ...over, + }; +} + +function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData( + queryKeys.encounterList(null, null, undefined), + list, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("paints the empty state, with one action, when there is no visit", () => { + const html = render({ upcoming: [], past: [] }); + expect(html).toContain("No visit recorded yet"); + expect(html).toContain("A date is enough"); + expect(html).toContain('data-slot="visits-add"'); + // An empty account is not an error, and the two states never share copy. + expect(html).not.toContain("Visits could not be loaded."); + expect(html).not.toContain('data-slot="visits-upcoming"'); + }); + + it("puts the upcoming list above the past one", () => { + const html = render({ + upcoming: [visit({ id: "later", status: "PLANNED" })], + past: [visit({ id: "earlier" })], + }); + const upcomingAt = html.indexOf('data-slot="visits-upcoming"'); + const pastAt = html.indexOf('data-slot="visits-past"'); + expect(upcomingAt).toBeGreaterThan(-1); + expect(pastAt).toBeGreaterThan(-1); + expect(upcomingAt).toBeLessThan(pastAt); + expect(html.indexOf('data-encounter-id="later"')).toBeLessThan( + html.indexOf('data-encounter-id="earlier"'), + ); + }); + + it("renders the server's order inside a list rather than re-sorting it", () => { + // Two rows deliberately handed over in an order the client has no reason + // to like. Re-sorting here would be the second place the ordering lives. + const html = render({ + upcoming: [], + past: [ + visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }), + visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }), + ], + }); + expect(html.indexOf('data-encounter-id="second"')).toBeLessThan( + html.indexOf('data-encounter-id="first"'), + ); + }); + + it("offers the address book beside the add action", () => { + const html = render({ upcoming: [], past: [] }); + expect(html).toContain('href="/checkups/practitioners"'); + }); +}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx new file mode 100644 index 000000000..5c611a276 --- /dev/null +++ b/src/components/encounters/encounter-form.tsx @@ -0,0 +1,283 @@ +"use client"; + +/** + * The visit form. + * + * **A visit saves with a date and nothing else.** No required practitioner, no + * required kind, no required link, and no Save button disabled until something + * is picked. That is the whole product constraint this feature exists to hold: + * a required field on a form somebody fills after a doctor's appointment is the + * bureaucracy it was built to avoid. The only thing that can block Save is a + * missing or unparseable date, because a visit with no instant is not a record + * of anything. + * + * Status is DERIVED from the date on a new visit, never asked: a future instant + * means PLANNED and a past one means DONE. A person booking an appointment + * should not have to also tell the app that it is in the future. On an existing + * visit the status becomes editable, because moving a planned visit to done, + * cancelled or a no-show is the transition that closes a checkup. + * + * The three link pickers are gated by the modules that own what they point at, + * and the gate BLANKS the block rather than post-filtering it — an empty + * picker for a module the account turned off advertises a feature that is not + * there. + */ +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { DateTimeField } from "@/components/ui/date-time-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters"; +import type { Practitioner } from "@/hooks/use-practitioners"; +import { + ENCOUNTER_KINDS, + ENCOUNTER_STATUSES, + encounterKindText, + encounterStatusText, +} from "./encounter-labels"; +import { PractitionerCombobox } from "./practitioner-combobox"; +import { EncounterLinkPickers } from "./encounter-link-pickers"; + +export interface EncounterDraft { + /** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */ + occurredAt: string; + status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW"; + kind: string; + practitioner: Practitioner | null; + reason: string; + outcome: string; + documentIds: string[]; + labResultIds: string[]; + episodeIds: string[]; +} + +function localValue(instant: Date): string { + const offset = instant.getTimezoneOffset(); + return new Date(instant.getTime() - offset * 60_000) + .toISOString() + .slice(0, 16); +} + +export function nowLocalValue(): string { + return localValue(new Date()); +} + +/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */ +export function draftInstant(value: string): string | null { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +export function emptyDraft( + overrides?: Partial, +): EncounterDraft { + return { + occurredAt: nowLocalValue(), + status: "DONE", + kind: "OTHER", + practitioner: null, + reason: "", + outcome: "", + documentIds: [], + labResultIds: [], + episodeIds: [], + ...overrides, + }; +} + +export function draftFromEncounter(row: Encounter): EncounterDraft { + return { + occurredAt: localValue(new Date(row.occurredAt)), + status: row.status, + kind: row.kind, + practitioner: row.practitioner, + reason: row.reason ?? "", + outcome: row.outcome ?? "", + documentIds: row.links?.documents.map((link) => link.id) ?? [], + labResultIds: row.links?.labResults.map((link) => link.id) ?? [], + episodeIds: row.links?.conditions.map((link) => link.id) ?? [], + }; +} + +/** + * The status a NEW visit carries, read off its own date. + * + * Exported so the sheet and its tests agree on the derivation rather than each + * spelling out the comparison. + */ +export function derivedStatus( + occurredAt: string, + now = Date.now(), +): "PLANNED" | "DONE" { + const parsed = new Date(occurredAt).getTime(); + return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE"; +} + +/** The body a draft becomes, with the empty strings turned into absences. */ +export function draftToBody( + draft: EncounterDraft, + options: { isEdit: boolean; reminderId?: string | null }, +): EncounterWriteBody | null { + const occurredAt = draftInstant(draft.occurredAt); + if (!occurredAt) return null; + return { + occurredAt, + status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt), + kind: draft.kind, + practitionerId: draft.practitioner?.id ?? null, + reason: draft.reason.trim() || null, + outcome: draft.outcome.trim() || null, + ...(options.reminderId ? { reminderId: options.reminderId } : {}), + documentIds: draft.documentIds, + labResultIds: draft.labResultIds, + episodeIds: draft.episodeIds, + }; +} + +export function EncounterForm({ + draft, + onChange, + isEdit, +}: { + draft: EncounterDraft; + onChange: (next: EncounterDraft) => void; + isEdit: boolean; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const patch = (part: Partial) => + onChange({ ...draft, ...part }); + + const planned = useMemo( + () => + isEdit + ? draft.status === "PLANNED" + : derivedStatus(draft.occurredAt) === "PLANNED", + [isEdit, draft.status, draft.occurredAt], + ); + + // The outcome field starts collapsed on a planned visit: there is nothing to + // write about a thing that has not happened. Derived rather than + // synchronised — a visit that already carries an outcome is never collapsed + // over it, and expressing that as an effect would be a second source of + // truth for something the draft already knows. + const [revealed, setRevealed] = useState(false); + const outcomeOpen = revealed || !planned || draft.outcome.length > 0; + + return ( +
+ + patch({ occurredAt: value })} + required + aria-required="true" + data-testid="encounter-occurred-at" + /> + + + {isEdit ? ( + + + patch({ + status: event.target.value as EncounterDraft["status"], + }) + } + > + {ENCOUNTER_STATUSES.map((status) => ( + + ))} + + + ) : null} + + + patch({ practitioner: next })} + /> + + + + patch({ kind: event.target.value })} + > + {ENCOUNTER_KINDS.map((kind) => ( + + ))} + + + + +