feat(mood): resolve sleep, activity, vitals and symptoms at read time

- one resolver reads the day's figures from the modules that own them; nothing
  is copied onto the mood row, so correcting a sleep session corrects it here
  in the same moment
- absence answers { present: false } rather than zero, because a night nobody
  recorded and a night of no sleep are different facts
- a module switched off blanks its whole block instead of filtering down to an
  empty one
- keyed by the local day so the capture sheet can ask before an entry exists
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:22:33 +02:00
1 parent 8eb36da228
commit 1f609d42e0
4 files changed
+662

No files matched your search

+74
View File
@@ -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);
});
+275
View File
@@ -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<T> =
{ 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<LinkedSleep>;
activity: LinkedBlock<LinkedActivity>;
/**
* 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<LinkedVitals>;
body: LinkedBlock<LinkedBody>;
}
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<T extends { measuredAt: Date }>(
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<LinkedDayContext> {
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<LinkedSleep> = 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<LinkedActivity> = activityOn
? {
available: true,
steps: sumOfDay(dayRows, "ACTIVITY_STEPS", "steps"),
activeEnergy: sumOfDay(dayRows, "ACTIVE_ENERGY_BURNED", "kcal"),
}
: MODULE_OFF;
const vitals: LinkedBlock<LinkedVitals> = {
available: true,
restingHeartRate: latestOfDay(dayRows, "RESTING_HEART_RATE", "bpm"),
heartRateVariability: latestOfDay(dayRows, "HEART_RATE_VARIABILITY", "ms"),
};
let body: LinkedBlock<LinkedBody> = 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 };
}
+16
View File
@@ -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,
@@ -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<LinkedBody> {
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);
});
});