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,