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] 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); + }); +});