diff --git a/src/app/api/mood/linked-context/route.ts b/src/app/api/mood/linked-context/route.ts new file mode 100644 index 000000000..ff038fc04 --- /dev/null +++ b/src/app/api/mood/linked-context/route.ts @@ -0,0 +1,74 @@ +/** + * `GET /api/mood/linked-context?date=YYYY-MM-DD` — what the modules that own + * sleep, activity, vitals and body symptoms already know about one day. + * + * Keyed by the local day rather than by an entry id, deliberately: the capture + * sheet needs these figures BEFORE an entry exists, and a route that could + * only answer for a saved row would leave the one surface that most wants + * them unable to ask. The detail view passes the entry's own `date` and `tz` + * and gets the same answer. + * + * Nothing here is stored on the mood row. Every figure is read from its owning + * module on each request, which is what makes a corrected sleep session show + * up on the mood entry with nothing to re-sync. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { resolveLinkedDayContext } from "@/lib/mood/linked-context"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + // The zone the day is anchored to. Absent falls back to the account's own + // display timezone, which is what a fresh capture sheet means; the detail + // view passes the entry's stored `tz` so a row logged elsewhere keeps its + // own day boundaries. + tz: z.string().min(1).max(64).optional(), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "mind"); + + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + annotate({ + action: { name: "mood.linked-context.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "mood.linkedContext.invalid", + }); + } + + const linked = await resolveLinkedDayContext(user.id, { + date: parsed.data.date, + tz: parsed.data.tz ?? user.timezone ?? DEFAULT_TIMEZONE, + }); + + annotate({ + action: { name: "mood.linked-context.read" }, + // Which blocks the person can see at all, so a support question about a + // missing figure can be answered without asking them to describe their + // module settings. + meta: { + day: linked.day, + sleep_available: linked.sleep.available, + activity_available: linked.activity.available, + body_available: linked.body.available, + }, + }); + + return apiSuccess(linked); +}); diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts new file mode 100644 index 000000000..59632f33f --- /dev/null +++ b/src/lib/mood/linked-context.ts @@ -0,0 +1,275 @@ +/** + * What a mood entry's day looked like in the modules that already own it. + * + * The rule this file exists to serve is the one the whole context feature + * hangs off: one fact, one home. Sleep belongs to the sleep module, steps and + * active energy to the activity data, resting heart rate and HRV to the + * measurement engine, body symptoms to the illness module. A mood entry shows + * them and never asks for them a second time, and **nothing here is copied + * onto the mood row** — every figure is resolved at read time from the rows + * that own it, so correcting a sleep session corrects it here too, in the same + * moment, with nothing to re-sync. + * + * Two consequences that are easy to get wrong and are therefore stated: + * + * * **Absence is `{ present: false }`, never `0`.** A night nobody recorded + * and a night of no sleep are different facts, and a surface that renders + * the first as the second is lying about somebody's health record. + * * **A module that is switched off blanks its block.** It does not answer + * zero and it does not answer a filtered-down version of itself — the + * block is simply not there, the same way `snapshot.ts` blanks rather than + * filters. Turning a module off is a statement about what the person wants + * to see, not a filter over what they wanted to see. + * + * The reads are bounded: one local day per entry, resolved through the day-key + * helper that already carries the legacy-Berlin fallback for `tz IS NULL` + * rows. There is no unbounded `findMany` here, and adding one would turn a + * detail view into a table scan on an account with years of history. + */ +import type { MeasurementType } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; + +/** A figure that either exists or honestly does not. */ +export type LinkedFigure = + { present: false } | { present: true; value: number; unit: string }; + +/** A block that can also be absent because its module is switched off. */ +export type LinkedBlock = + { available: false; reason: "module-disabled" } | ({ available: true } & T); + +export interface LinkedSleep { + /** Time asleep for the night the entry's local day woke up on. */ + asleep: LinkedFigure; + /** Time in bed, when any writer recorded a bed window. */ + inBed: LinkedFigure; +} + +export interface LinkedActivity { + steps: LinkedFigure; + activeEnergy: LinkedFigure; +} + +export interface LinkedVitals { + restingHeartRate: LinkedFigure; + heartRateVariability: LinkedFigure; +} + +export interface LinkedBody { + /** Whether an illness day-log exists for this day at all. */ + logged: boolean; + /** How much the day was limited, 0-3, as the illness module records it. */ + functionalImpact: LinkedFigure; + /** Symptoms linked to that day-log, by their catalogue key. */ + symptoms: string[]; + /** The episode to open in the illness module, when there is one. */ + episodeId: string | null; +} + +export interface LinkedDayContext { + /** The entry's local day, resolved through the entry's own `tz`. */ + day: string; + sleep: LinkedBlock; + activity: LinkedBlock; + /** + * Resting heart rate and HRV. Not gated: the measurement engine is core and + * has no off switch, so this block is always available and answers absence + * per figure instead. + */ + vitals: LinkedBlock; + body: LinkedBlock; +} + +const MODULE_OFF = { available: false, reason: "module-disabled" } as const; +const ABSENT: LinkedFigure = { present: false }; + +function figure(value: number | null | undefined, unit: string): LinkedFigure { + // A stored zero is a real reading and stays one. Only a missing row is + // absent, which is why this tests for null rather than for falsiness. + return value === null || value === undefined + ? ABSENT + : { present: true, value, unit }; +} + +/** Sum a day's rows for a cumulative metric; no rows means absent, not zero. */ +function sumOfDay( + rows: ReadonlyArray<{ type: MeasurementType; value: number }>, + type: MeasurementType, + unit: string, +): LinkedFigure { + const matching = rows.filter((r) => r.type === type); + if (matching.length === 0) return ABSENT; + return { + present: true, + value: matching.reduce((sum, r) => sum + r.value, 0), + unit, + }; +} + +/** Latest reading of the day for a point-in-time metric. */ +function latestOfDay( + rows: ReadonlyArray<{ + type: MeasurementType; + value: number; + measuredAt: Date; + }>, + type: MeasurementType, + unit: string, +): LinkedFigure { + const matching = rows + .filter((r) => r.type === type) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + if (matching.length === 0) return ABSENT; + return { present: true, value: matching[matching.length - 1].value, unit }; +} + +/** + * A UTC window guaranteed to contain one local day, whatever its zone. + * + * Generous on both sides on purpose: the exact boundaries are decided by + * `moodDateKey` afterwards, and this only has to be sure it did not clip the + * day off at either end. Doing the arithmetic here instead would mean + * re-deriving a zone offset that the day-key helper already knows, and that + * second derivation is where the DST hour goes missing. + */ +function localDayWindow(day: string): { from: Date; to: Date } { + // Walk outwards from the naive UTC midnight and keep whatever the day-key + // helper agrees belongs to this day. Cheaper and far safer than arithmetic + // on `new Date(y, m, d)`, which slips by an hour twice a year and has + // already been solved once in this codebase. + const naive = new Date(`${day}T00:00:00.000Z`); + const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000); + const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000); + return { from, to }; +} + +/** Keep only the rows whose own local day is the one asked for. */ +function onDay( + rows: readonly T[], + day: string, + tz: string, +): T[] { + return rows.filter((r) => moodDateKey(r.measuredAt, tz) === day); +} + +/** The measurement types the linked block reads. One query covers all four. */ +const LINKED_MEASUREMENT_TYPES: MeasurementType[] = [ + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", +]; + +/** + * Resolve the linked figures for one entry's local day. + * + * Takes the entry's own `date` and `tz` rather than re-deriving them, because + * the row already decided which day it belongs to and a second derivation here + * could disagree with it — which is precisely the class of bug the per-row + * `tz` column was added to end. + */ +export async function resolveLinkedDayContext( + userId: string, + entry: { date: string; tz: string | null }, +): Promise { + const tz = entry.tz ?? DEFAULT_TIMEZONE; + const day = entry.date; + const { from, to } = localDayWindow(day); + + const [sleepOn, activityOn, illnessOn] = await Promise.all([ + isModuleEnabled(userId, "sleep"), + isModuleEnabled(userId, "workouts"), + isModuleEnabled(userId, "illness"), + ]); + + const measurements = 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, + }, + orderBy: { measuredAt: "asc" }, + }); + + const dayRows = onDay(measurements, day, tz); + + let sleep: LinkedBlock = MODULE_OFF; + if (sleepOn) { + // The stage rows of the whole window, not just the day's, and then the + // night whose WAKE day is this one. A night starts the evening before, so + // filtering the raw rows by day first would cut it in half — the + // reconstruction is the thing that knows where a night begins. + const stageRows = measurements.filter((r) => r.type === "SLEEP_DURATION"); + if (stageRows.length === 0) { + sleep = { available: true, asleep: ABSENT, inBed: ABSENT }; + } else { + const priority = await loadUserSourcePriority(userId); + const night = reconstructSleepNights( + stageRows as unknown as SleepStageRow[], + tz, + priority, + ).find((n) => n.night === day); + sleep = { + available: true, + asleep: figure(night?.asleepMinutes ?? null, "min"), + inBed: figure(night?.inBedMinutes ?? null, "min"), + }; + } + } + + const activity: LinkedBlock = activityOn + ? { + available: true, + steps: sumOfDay(dayRows, "ACTIVITY_STEPS", "steps"), + activeEnergy: sumOfDay(dayRows, "ACTIVE_ENERGY_BURNED", "kcal"), + } + : MODULE_OFF; + + const vitals: LinkedBlock = { + available: true, + restingHeartRate: latestOfDay(dayRows, "RESTING_HEART_RATE", "bpm"), + heartRateVariability: latestOfDay(dayRows, "HEART_RATE_VARIABILITY", "ms"), + }; + + let body: LinkedBlock = MODULE_OFF; + if (illnessOn) { + // Read only. The illness module owns symptom capture and its severity + // scale; the mood surface links into it and captures nothing, which is + // what keeps the two from disagreeing about the same day. + const dayLog = await prisma.illnessDayLog.findFirst({ + where: { userId, date: day, deletedAt: null }, + select: { + episodeId: true, + functionalImpact: true, + symptomLinks: { + select: { severity: true, symptom: { select: { key: true } } }, + }, + }, + }); + body = { + available: true, + logged: dayLog !== null, + functionalImpact: figure(dayLog?.functionalImpact ?? null, "level"), + symptoms: dayLog?.symptomLinks.map((l) => l.symptom.key) ?? [], + episodeId: dayLog?.episodeId ?? null, + }; + } + + return { day, sleep, activity, vitals, body }; +} diff --git a/src/lib/query-keys/mood.ts b/src/lib/query-keys/mood.ts index 0bc580a2c..bde824988 100644 --- a/src/lib/query-keys/mood.ts +++ b/src/lib/query-keys/mood.ts @@ -42,6 +42,22 @@ export const moodKeys = { */ moodEntriesDay: (date: string) => ["mood-entries", "day", date] as const, + /** + * v1.38 — what the sleep, activity, vitals and illness modules already know + * about one local day, read by the mood capture sheet and the entry detail. + * + * Keyed by the day AND the zone: the same calendar date read under two + * timezones covers two different windows, and one key for both would serve + * whichever answer arrived first. + * + * Its own prefix rather than a segment under `["mood-entries"]`, and + * deliberately: nothing here comes from a mood row, so a mood write has no + * reason to invalidate it, and putting it under that prefix would refetch + * four modules' figures every time somebody tapped a face. + */ + moodLinkedContext: (date: string, tz: string) => + ["mood-linked-context", date, tz] as const, + moodAnalytics: () => ["mood-analytics"] as const, /** * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, diff --git a/tests/integration/mood-context-linkage.test.ts b/tests/integration/mood-context-linkage.test.ts new file mode 100644 index 000000000..98313b0be --- /dev/null +++ b/tests/integration/mood-context-linkage.test.ts @@ -0,0 +1,297 @@ +/** + * v1.38 — the linked day block, proven against real Postgres to be a read of + * the owning module rather than a copy of it. + * + * The claim the whole design rests on cannot be made by a shape test. "The + * response has a `sleep` key" would stay green over an implementation that + * copied the figure onto the mood row at write time and never looked at it + * again — and that implementation is exactly the failure mode this feature + * exists to avoid. So the test changes the SOURCE and reads the mood surface + * again: write a sleep session, read the block, correct the session, read + * again, assert the block moved. + * + * Beside it, the two absences that are easy to get wrong: + * - a module switched off blanks its block rather than answering zero; + * - a day with no rows answers `{ present: false }`, which is a different + * fact from a day of no sleep. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-mood-linkage"; +const DAY = "2026-06-11"; + +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), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "mood-linkage", + email: "mood-linkage@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +interface LinkedFigure { + present: boolean; + value?: number; + unit?: string; +} +interface LinkedBody { + available: boolean; + reason?: string; + day: string; + sleep: { available: boolean; asleep?: LinkedFigure; inBed?: LinkedFigure }; + activity: { + available: boolean; + reason?: string; + steps?: LinkedFigure; + activeEnergy?: LinkedFigure; + }; + vitals: { + available: boolean; + restingHeartRate?: LinkedFigure; + heartRateVariability?: LinkedFigure; + }; + body: { + available: boolean; + reason?: string; + logged?: boolean; + symptoms?: string[]; + episodeId?: string | null; + }; +} + +async function readLinked(day = DAY): Promise { + const { GET } = await import("@/app/api/mood/linked-context/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/mood/linked-context?date=${day}&tz=Europe%2FBerlin`, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: LinkedBody }; + return body.data; +} + +/** One night's sleep, as the sleep module stores it: minutes per stage. */ +async function writeSleep(minutes: number) { + return getPrismaClient().measurement.create({ + data: { + userId: TEST_USER_ID, + type: "SLEEP_DURATION", + value: minutes, + unit: "min", + // A night that ends on the morning of DAY: the reconstruction keys a + // night on the day it woke up on. + measuredAt: new Date("2026-06-11T05:30:00.000Z"), + sleepStage: "ASLEEP", + source: "MANUAL", + }, + }); +} + +describe("mood context linkage (real Postgres)", () => { + it("a corrected sleep session moves the linked figure, because nothing was copied", async () => { + const row = await writeSleep(400); + + const before = await readLinked(); + expect(before.sleep.available).toBe(true); + expect(before.sleep.asleep).toEqual({ + present: true, + value: 400, + unit: "min", + }); + + // The correction, made where the fact lives. + await getPrismaClient().measurement.update({ + where: { id: row.id }, + data: { value: 455 }, + }); + + const after = await readLinked(); + expect(after.sleep.asleep).toEqual({ + present: true, + value: 455, + unit: "min", + }); + }); + + it("a day with no rows answers absence, not zero", async () => { + const linked = await readLinked(); + expect(linked.sleep.available).toBe(true); + expect(linked.sleep.asleep).toEqual({ present: false }); + expect(linked.activity.steps).toEqual({ present: false }); + expect(linked.vitals.restingHeartRate).toEqual({ present: false }); + // Never a zero: a night nobody recorded and a night of no sleep are + // different facts, and only one of them belongs in a health record. + expect(JSON.stringify(linked)).not.toContain('"value":0'); + }); + + it("sums the day's activity and takes the day's latest vital", async () => { + await getPrismaClient().measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 4000, + unit: "steps", + measuredAt: new Date("2026-06-11T08:00:00.000Z"), + source: "MANUAL", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 2500, + unit: "steps", + measuredAt: new Date("2026-06-11T18:00:00.000Z"), + source: "MANUAL", + }, + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 61, + unit: "bpm", + measuredAt: new Date("2026-06-11T07:00:00.000Z"), + source: "MANUAL", + }, + { + userId: TEST_USER_ID, + type: "RESTING_HEART_RATE", + value: 58, + unit: "bpm", + measuredAt: new Date("2026-06-11T21:00:00.000Z"), + source: "MANUAL", + }, + // The day before, to prove the window does not bleed. + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 9999, + unit: "steps", + measuredAt: new Date("2026-06-10T12:00:00.000Z"), + source: "MANUAL", + }, + ], + }); + + const linked = await readLinked(); + expect(linked.activity.steps).toEqual({ + present: true, + value: 6500, + unit: "steps", + }); + expect(linked.vitals.restingHeartRate).toEqual({ + present: true, + value: 58, + unit: "bpm", + }); + }); + + it("blanks the sleep block when the sleep module is off, rather than answering zero", async () => { + await writeSleep(400); + expect((await readLinked()).sleep.available).toBe(true); + + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { modulePreferencesJson: { sleep: false } }, + }); + + const linked = await readLinked(); + expect(linked.sleep).toEqual({ + available: false, + reason: "module-disabled", + }); + // The block is gone, not zeroed and not filtered down to an empty figure. + expect(linked.sleep).not.toHaveProperty("asleep"); + // And the other blocks are untouched by that choice. + expect(linked.activity.available).toBe(true); + expect(linked.vitals.available).toBe(true); + }); + + it("links the illness day-log read-only, and blanks it when the module is off", async () => { + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId: TEST_USER_ID, + label: "cold", + type: "INFECTION", + onsetAt: new Date("2026-06-10T08:00:00.000Z"), + }, + }); + // The symptom catalogue is seeded reference data every instance has, so + // this upserts rather than creating: the point of the test is the link, + // not the row. + const symptom = await getPrismaClient().illnessSymptom.upsert({ + where: { key: "cough" }, + create: { key: "cough", labelKey: "illness.symptom.cough" }, + update: {}, + }); + await getPrismaClient().illnessDayLog.create({ + data: { + userId: TEST_USER_ID, + episodeId: episode.id, + date: DAY, + functionalImpact: 2, + symptomLinks: { create: [{ symptomId: symptom.id, severity: 2 }] }, + }, + }); + + const linked = await readLinked(); + expect(linked.body.available).toBe(true); + expect(linked.body.logged).toBe(true); + expect(linked.body.symptoms).toEqual(["cough"]); + expect(linked.body.episodeId).toBe(episode.id); + + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { modulePreferencesJson: { illness: false } }, + }); + expect((await readLinked()).body).toEqual({ + available: false, + reason: "module-disabled", + }); + }); + + it("refuses a malformed day", async () => { + const { GET } = await import("@/app/api/mood/linked-context/route"); + const res = await GET( + new NextRequest( + "http://localhost/api/mood/linked-context?date=yesterday", + ), + ); + expect(res.status).toBe(422); + }); +});