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