mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
8eb36da228
commit
1f609d42e0
4 files changed
+662
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user