/** * v1.42 (#613) — the day view, its index, the visit preparation, the * timeline and life events, driven as routes against real Postgres. * * What the unit tests cannot show: that each of the three ways this schema * stores a day lands on the right local day across a clock change, that a * switched-off module's rows are not in the answer, that a grant scoped to * some sections reads exactly those sections, that a life event is encrypted * at rest, audited, and never reaches the model-facing read of the same day. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; 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), })); type Handler = ( request: NextRequest, // eslint-disable-next-line @typescript-eslint/no-explicit-any context: { params: Promise }, ) => Promise; let counter = 0; async function makeUser(label: string, modules: Record = {}) { const suffix = `${label}-${counter++}`; return getPrismaClient().user.create({ data: { username: `dv-${suffix}`, email: `dv-${suffix}@example.test`, displayName: `DV ${suffix}`, role: "USER", timezone: "Europe/Berlin", locale: "en", modulePreferencesJson: modules, }, }); } async function signIn(userId: string) { const session = await getPrismaClient().session.create({ data: { userId, expiresAt: new Date(Date.now() + 60_000) }, }); cookieJar.set("healthlog_session", session.id); return session; } async function shareWith( ownerId: string, delegateId: string, scope: Array< | "measurements" | "medications" | "labs" | "profile" | "illness" | "mind" | "cycle" | "documents" > | null, ) { const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); const invited = await inviteGrant({ grantorId: ownerId, granteeId: delegateId, access: "READ", scope, }); await acceptGrant({ grantId: invited.id, granteeId: delegateId }); const session = await signIn(delegateId); await switchSessionTo(session.id, ownerId); } async function call( handler: Handler, method: string, url: string, body?: unknown, params: Record = {}, ): Promise { return handler( new NextRequest(`http://localhost${url}`, { method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }), { params: Promise.resolve(params) }, ); } async function json(response: Response): Promise { return ((await response.json()) as { data: T }).data; } async function getDay(date: string) { const { GET } = await import("@/app/api/day/[date]/route"); return call(GET as Handler, "GET", `/api/day/${date}`, undefined, { date }); } type Day = import("@/lib/day/contract").DayResponse; beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); }); describe("a local day across a clock change", () => { it("cuts readings, date strings and calendar dates on the record's own day", async () => { const db = getPrismaClient(); const owner = await makeUser("tz"); // 00:30 local on the spring-forward day is still the 28th in UTC. await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 80, unit: "kg", measuredAt: new Date("2026-03-28T23:30:00.000Z"), }, }); // 23:59 local (CEST) is inside the 23-hour day; midnight is not. await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 81, unit: "kg", measuredAt: new Date("2026-03-29T21:59:00.000Z"), }, }); await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 82, unit: "kg", measuredAt: new Date("2026-03-29T22:00:00.000Z"), }, }); // A mood entry filed in New York on the 29th, logged when Berlin was // already on the 30th: the row's own date decides. await db.moodEntry.create({ data: { userId: owner.id, date: "2026-03-29", tz: "America/New_York", mood: "GUT", score: 4, moodLoggedAt: new Date("2026-03-30T02:00:00.000Z"), }, }); // A calendar date: starts on the 29th whatever the zone. await db.medication.create({ data: { userId: owner.id, name: "Ramipril", dose: "5 mg", startsOn: dayKeyAsUtcMidnight("2026-03-29"), }, }); await signIn(owner.id); const day = await json(await getDay("2026-03-29")); expect(day.tz).toBe("Europe/Berlin"); expect(day.values.map((v) => v.value).sort()).toEqual([80, 81]); expect(day.events.map((e) => e.kind)).toEqual( expect.arrayContaining(["mood", "medicationStart"]), ); expect(day.running.find((r) => r.kind === "medication")).toMatchObject({ title: "Ramipril", since: "2026-03-29", dayIndex: 1, }); const before = await json(await getDay("2026-03-28")); expect(before.values).toEqual([]); expect(before.events.some((e) => e.kind === "mood")).toBe(false); const after = await json(await getDay("2026-03-30")); expect(after.values.map((v) => v.value)).toEqual([82]); expect(after.events.some((e) => e.kind === "mood")).toBe(false); }); it("runs only records with a start of their own, never a profile fact or an entry date", async () => { const db = getPrismaClient(); const owner = await makeUser("run"); // Filed on the 17th: a person who quit years ago would read "day 13". await db.healthProfileFactRevision.create({ data: { userId: owner.id, kind: "SMOKING_STATUS", valueEncrypted: encryptToBytes("FORMER"), validFrom: new Date("2026-03-17T08:00:00.000Z"), provenance: "USER_REPORTED", }, }); // A medication filed on the 17th without a start date. await db.medication.create({ data: { userId: owner.id, name: "Levothyroxine", dose: "50 µg", createdAt: new Date("2026-03-17T08:00:00.000Z"), }, }); await signIn(owner.id); const day = await json(await getDay("2026-03-29")); expect(day.running.map((r) => r.kind)).toEqual(["medication"]); expect(day.running[0]).toMatchObject({ title: "Levothyroxine", since: null, dayIndex: null, dayCount: null, }); // The entry's date still bounds which days show it. const before = await json(await getDay("2026-03-16")); expect(before.running).toEqual([]); }); it("lists a medication once, with its course and the dose of that day", async () => { const db = getPrismaClient(); const owner = await makeUser("once"); const glp = await db.medication.create({ data: { userId: owner.id, name: "Mounjaro", dose: "7.5 mg", startsOn: dayKeyAsUtcMidnight("2026-01-15"), }, }); await db.medicationCourse.create({ data: { userId: owner.id, medicationId: glp.id, startsOn: dayKeyAsUtcMidnight("2026-01-15"), }, }); for (const [from, value] of [ ["2026-01-15", 2.5], ["2026-02-12", 5], ["2026-04-09", 7.5], ] as const) { await db.medicationDoseChange.create({ data: { medicationId: glp.id, effectiveFrom: new Date(`${from}T08:00:00.000Z`), doseValue: value, doseUnit: "mg", }, }); } const ace = await db.medication.create({ data: { userId: owner.id, name: "Ramipril", dose: "5 mg", startsOn: dayKeyAsUtcMidnight("2026-03-12"), }, }); await db.medicationCourse.create({ data: { userId: owner.id, medicationId: ace.id, startsOn: dayKeyAsUtcMidnight("2026-03-12"), }, }); const pausedMed = await db.medication.create({ data: { userId: owner.id, name: "Metformin", dose: "500 mg", startsOn: dayKeyAsUtcMidnight("2026-01-01"), }, }); await db.medicationPauseEra.create({ data: { userId: owner.id, medicationId: pausedMed.id, pausedAt: new Date("2026-03-01T08:00:00.000Z"), resumedAt: new Date("2026-03-20T08:00:00.000Z"), }, }); await signIn(owner.id); const march = await json(await getDay("2026-03-15")); const names = march.running.map((r) => r.title).sort(); expect(names).toEqual(["Metformin", "Mounjaro", "Ramipril"]); const mj = march.running.find((r) => r.title === "Mounjaro"); // The course lends its days; the dose is the one in effect that day. expect(mj).toMatchObject({ kind: "medicationCourse", sub: "5 mg" }); expect(march.running.find((r) => r.title === "Ramipril")).toMatchObject({ kind: "medicationCourse", sub: "5 mg", }); // Paused that day: one entry, the paused one. expect(march.running.find((r) => r.title === "Metformin")).toMatchObject({ kind: "medicationPause", sub: null, }); const january = await json(await getDay("2026-01-20")); expect(january.running.filter((r) => r.title === "Mounjaro")).toEqual([ expect.objectContaining({ sub: "2.5 mg" }), ]); }); it("holds 25 hours on the fall-back day", async () => { const db = getPrismaClient(); const owner = await makeUser("fall"); // 23:30 CET on the 25th, after the extra hour. await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 79, unit: "kg", measuredAt: new Date("2026-10-25T22:30:00.000Z"), }, }); // 00:30 CEST on the 25th, before the change. await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 78, unit: "kg", measuredAt: new Date("2026-10-24T22:30:00.000Z"), }, }); await signIn(owner.id); const day = await json(await getDay("2026-10-25")); expect(day.values.map((v) => v.value).sort()).toEqual([78, 79]); }); it("refuses a date that is not a calendar date", async () => { const owner = await makeUser("bad"); await signIn(owner.id); const response = await getDay("2026-02-30"); expect(response.status).toBe(422); }); }); describe("modules", () => { it("leaves a switched-off module's rows out without naming them", async () => { const db = getPrismaClient(); const owner = await makeUser("mod", { mood: false, recovery: false }); await db.moodEntry.create({ data: { userId: owner.id, date: "2026-03-10", tz: "Europe/Berlin", mood: "GUT", score: 4, moodLoggedAt: new Date("2026-03-10T08:00:00.000Z"), }, }); await db.measurement.create({ data: { userId: owner.id, type: "HEART_RATE_VARIABILITY", value: 45, unit: "ms", measuredAt: new Date("2026-03-10T06:00:00.000Z"), }, }); await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 80, unit: "kg", measuredAt: new Date("2026-03-10T06:00:00.000Z"), }, }); await signIn(owner.id); const day = await json(await getDay("2026-03-10")); expect(day.events).toEqual([]); expect(day.values.map((v) => v.type)).toEqual(["WEIGHT"]); expect(day.sections).toEqual({}); }); it("answers the timeline only with the opt-in module on", async () => { const off = await makeUser("tl-off"); await signIn(off.id); const { GET } = await import("@/app/api/timeline/route"); const refused = await call(GET as Handler, "GET", "/api/timeline"); expect(refused.status).toBe(403); const on = await makeUser("tl-on", { timeline: true }); await signIn(on.id); const ok = await call(GET as Handler, "GET", "/api/timeline"); expect(ok.status).toBe(200); }); }); describe("sharing", () => { async function seedOwner() { const db = getPrismaClient(); const owner = await makeUser("share"); await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 80, unit: "kg", measuredAt: new Date("2026-03-10T06:00:00.000Z"), }, }); const med = await db.medication.create({ data: { userId: owner.id, name: "Ramipril", dose: "5 mg" }, }); await db.medicationIntakeEvent.create({ data: { userId: owner.id, medicationId: med.id, scheduledFor: new Date("2026-03-10T07:00:00.000Z"), takenAt: new Date("2026-03-10T07:05:00.000Z"), }, }); await db.moodEntry.create({ data: { userId: owner.id, date: "2026-03-10", tz: "Europe/Berlin", mood: "GUT", score: 4, moodLoggedAt: new Date("2026-03-10T08:00:00.000Z"), }, }); return owner; } it("reads only the sections a scoped grant covers and names the rest", async () => { const owner = await seedOwner(); const delegate = await makeUser("scoped"); await shareWith(owner.id, delegate.id, ["measurements"]); const response = await getDay("2026-03-10"); expect(response.status).toBe(200); const day = await json(response); expect(day.values.map((v) => v.value)).toEqual([80]); expect(day.events).toEqual([]); expect(day.sections.medications).toEqual({ available: false, reason: "not_shared", }); expect(day.sections.mood).toEqual({ available: false, reason: "not_shared", }); expect(day.sections.values).toBeUndefined(); }); it("opens the covered sections of a wider grant", async () => { const owner = await seedOwner(); const delegate = await makeUser("wider"); await shareWith(owner.id, delegate.id, ["measurements", "medications"]); const day = await json(await getDay("2026-03-10")); expect(day.events.map((e) => e.kind)).toEqual(["intake"]); expect(day.sections.mood?.reason).toBe("not_shared"); }); it("refuses a grant that does not open the readings", async () => { const owner = await seedOwner(); const delegate = await makeUser("mind-only"); await shareWith(owner.id, delegate.id, ["mind"]); expect((await getDay("2026-03-10")).status).toBe(403); }); it("gives a full grant the record but keeps the environment for the owner", async () => { const owner = await seedOwner(); const delegate = await makeUser("full"); await shareWith(owner.id, delegate.id, null); const day = await json(await getDay("2026-03-10")); expect(day.events.map((e) => e.kind).sort()).toEqual(["intake", "mood"]); expect(day.sections).toEqual({ environment: { available: false, reason: "not_shared" }, }); }); it("narrows the day index the same way", async () => { const owner = await seedOwner(); const delegate = await makeUser("index"); await shareWith(owner.id, delegate.id, ["measurements"]); const { GET } = await import("@/app/api/day/index/route"); const index = await json<{ days: Record }>( await call( GET as Handler, "GET", "/api/day/index?from=2026-03-01&to=2026-03-31", ), ); expect(index.days).toEqual({ "2026-03-10": ["values"] }); }); }); describe("life events", () => { it("encrypts, audits, shows in the day and never reaches the model read", async () => { const db = getPrismaClient(); const owner = await makeUser("le", { timeline: true }); await signIn(owner.id); const { POST, GET } = await import("@/app/api/life-events/route"); const created = await call(POST as Handler, "POST", "/api/life-events", { category: "LOSS", startDate: "2026-03-10", precision: "DAY", title: "Zqx grandmother", note: "Zqx note", }); expect(created.status).toBe(201); const event = await json<{ id: string; title: string }>(created); expect(event.title).toBe("Zqx grandmother"); const row = await db.lifeEvent.findUniqueOrThrow({ where: { id: event.id }, }); expect(Buffer.from(row.titleEncrypted).toString("utf8")).not.toContain( "Zqx", ); const audit = await db.auditLog.findFirst({ where: { action: "life_event.create" }, }); expect(audit?.details ?? "").not.toContain("Zqx"); const list = await json<{ events: Array<{ title: string }> }>( await call(GET as Handler, "GET", "/api/life-events"), ); expect(list.events.map((e) => e.title)).toEqual(["Zqx grandmother"]); const day = await json(await getDay("2026-03-10")); expect(day.events.map((e) => e.kind)).toEqual(["lifeEvent"]); await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 80, unit: "kg", measuredAt: new Date("2026-03-10T06:00:00.000Z"), }, }); const { executeCoachTool } = await import("@/lib/ai/coach/tools/executor"); const forModel = await executeCoachTool({ userId: owner.id, name: "get_day", rawArguments: JSON.stringify({ date: "2026-03-10" }), }); expect(forModel.present).toBe(true); expect(JSON.stringify(forModel)).not.toMatch(/Zqx|lifeEvent/); // Edit with a misaligned precision is refused; a valid one lands. const { PATCH, DELETE } = await import("@/app/api/life-events/[id]/route"); const bad = await call( PATCH as Handler, "PATCH", `/api/life-events/${event.id}`, { precision: "YEAR" }, { id: event.id }, ); expect(bad.status).toBe(422); const good = await call( PATCH as Handler, "PATCH", `/api/life-events/${event.id}`, { category: "FAMILY" }, { id: event.id }, ); expect(good.status).toBe(200); const removed = await call( DELETE as Handler, "DELETE", `/api/life-events/${event.id}`, undefined, { id: event.id }, ); expect(removed.status).toBe(200); expect( (await db.lifeEvent.findUniqueOrThrow({ where: { id: event.id } })) .deletedAt, ).not.toBeNull(); const again = await call( DELETE as Handler, "DELETE", `/api/life-events/${event.id}`, undefined, { id: event.id }, ); expect(again.status).toBe(404); }); it("refuses another record's event as not found", async () => { const db = getPrismaClient(); const a = await makeUser("a"); const b = await makeUser("b"); const theirs = await db.lifeEvent.create({ data: { userId: a.id, category: "HOME", startDate: "2020-01-01", precision: "YEAR", titleEncrypted: encryptToBytes("Moved"), }, }); await signIn(b.id); const { PATCH } = await import("@/app/api/life-events/[id]/route"); const response = await call( PATCH as Handler, "PATCH", `/api/life-events/${theirs.id}`, { category: "WORK" }, { id: theirs.id }, ); expect(response.status).toBe(404); }); }); describe("life events are the owner's only", () => { async function seedOwnerWithEvent() { const db = getPrismaClient(); const owner = await makeUser("le-owner", { timeline: true }); await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 80, unit: "kg", measuredAt: new Date("2026-03-10T06:00:00.000Z"), }, }); const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); const event = await db.lifeEvent.create({ data: { userId: owner.id, category: "LOSS", startDate: "2026-03-10", precision: "DAY", titleEncrypted: encryptToBytes("Zqx private"), }, }); return { owner, event }; } async function manageWith(ownerId: string, delegateId: string) { const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); const invited = await inviteGrant({ grantorId: ownerId, granteeId: delegateId, access: "MANAGE", scope: null, }); await acceptGrant({ grantId: invited.id, granteeId: delegateId }); const session = await signIn(delegateId); await switchSessionTo(session.id, ownerId); } const grants: Array< [string, (ownerId: string, delegateId: string) => Promise] > = [ ["a profile share", (o, d) => shareWith(o, d, ["profile", "measurements"])], ["a whole-record share", (o, d) => shareWith(o, d, null)], ["a MANAGE share", manageWith], ]; for (const [label, grant] of grants) { it(`shows none to ${label}`, async () => { const { owner, event } = await seedOwnerWithEvent(); const delegate = await makeUser("le-delegate"); await grant(owner.id, delegate.id); const routes = await import("@/app/api/life-events/route"); const byId = await import("@/app/api/life-events/[id]/route"); expect( (await call(routes.GET as Handler, "GET", "/api/life-events")).status, ).toBe(403); expect( ( await call(routes.POST as Handler, "POST", "/api/life-events", { category: "WORK", startDate: "2024-01-01", precision: "YEAR", title: "New job", }) ).status, ).toBe(403); expect( ( await call( byId.PATCH as Handler, "PATCH", `/api/life-events/${event.id}`, { category: "FAMILY" }, { id: event.id }, ) ).status, ).toBe(403); expect( ( await call( byId.DELETE as Handler, "DELETE", `/api/life-events/${event.id}`, undefined, { id: event.id }, ) ).status, ).toBe(403); const day = await json(await getDay("2026-03-10")); expect(day.values.map((v) => v.value)).toEqual([80]); expect(JSON.stringify(day)).not.toMatch(/Zqx|"lifeEvent"/); expect(day.sections.lifeEvents).toEqual({ available: false, reason: "not_shared", }); const { GET: index } = await import("@/app/api/day/index/route"); const days = await json<{ days: Record }>( await call( index as Handler, "GET", "/api/day/index?from=2026-03-01&to=2026-03-31", ), ); expect(days.days["2026-03-10"]).not.toContain("lifeEvents"); const { GET: timeline } = await import("@/app/api/timeline/route"); const lanes = await call( timeline as Handler, "GET", "/api/timeline?zoom=year&to=2026-03-31", ); if (lanes.status === 200) { const body = await json(lanes); expect(body.lanes.map((l) => l.key)).not.toContain("life"); expect(JSON.stringify(body)).not.toContain("Zqx"); } else { expect(lanes.status).toBe(403); } const { GET: readiness } = await import("@/app/api/timeline/readiness/route"); const ready = await call( readiness as Handler, "GET", "/api/timeline/readiness", ); if (ready.status === 200) { const body = await json( ready, ); expect(body.lanes.map((l) => l.key)).not.toContain("life"); } else { expect(ready.status).toBe(403); } // The event is untouched. const row = await getPrismaClient().lifeEvent.findUniqueOrThrow({ where: { id: event.id }, }); expect([row.category, row.deletedAt]).toEqual(["LOSS", null]); }); } it("shows the owner their own events in the timeline", async () => { const { owner } = await seedOwnerWithEvent(); await signIn(owner.id); const { GET: timeline } = await import("@/app/api/timeline/route"); const body = await json( await call( timeline as Handler, "GET", "/api/timeline?zoom=year&to=2026-03-31", ), ); expect(body.lanes.map((l) => l.key)).toContain("life"); }); }); describe("notable days", () => { it("marks a three-month high in the day, the index and the visit window", async () => { const db = getPrismaClient(); const owner = await makeUser("notable"); const rows = []; for (let i = 0; i < 150; i += 1) { rows.push({ userId: owner.id, type: "WEIGHT" as const, value: 80, unit: "kg", measuredAt: new Date(Date.UTC(2025, 10, 1, 7) + i * 86_400_000), }); } await db.measurement.createMany({ data: rows }); // 2026-03-31 (day 150) reads higher than every day before. await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 84, unit: "kg", measuredAt: new Date("2026-03-31T07:00:00.000Z"), }, }); await db.encounter.create({ data: { userId: owner.id, occurredAt: new Date("2026-02-01T09:00:00.000Z"), status: "DONE", kind: "ROUTINE", }, }); await signIn(owner.id); const day = await json(await getDay("2026-03-31")); expect(day.notable).toEqual([ { kind: "extremeHigh", type: "WEIGHT", params: { since: "2025-11-01", value: 84 }, }, ]); expect(day.values[0].band).toMatchObject({ lo: expect.any(Number) }); const first = await json(await getDay("2025-11-01")); expect(first.notable).toEqual([ { kind: "firstValue", type: "WEIGHT", params: { type: "WEIGHT" } }, ]); const { GET: index } = await import("@/app/api/day/index/route"); const idx = await json<{ notable: string[] }>( await call( index as Handler, "GET", "/api/day/index?from=2026-03-01&to=2026-03-31", ), ); expect(idx.notable).toEqual(["2026-03-31"]); const { GET: notable } = await import("@/app/api/day/notable/route"); const window = await json<{ from: string; anchor: string; observations: Array<{ date: string; kind: string }>; }>(await call(notable as Handler, "GET", "/api/day/notable?to=2026-04-30")); expect(window.anchor).toBe("lastVisit"); expect(window.from).toBe("2026-02-01"); expect(window.observations.map((o) => [o.date, o.kind])).toEqual([ ["2026-03-31", "extremeHigh"], ["2026-04-01", "gap"], ]); }); it("anchors a window without `from` where the next planned visit's preparation starts", async () => { // The web passes `preparation.from` of the next booked visit; a client // that sends no `from` (the shipped iOS app) must read the same window, // not the one since the last completed visit of any kind. const db = getPrismaClient(); const owner = await makeUser("notable-next"); const cardiologist = await db.practitioner.create({ data: { userId: owner.id, name: "Practice A", specialty: "Cardiology" }, }); const now = Date.now(); const daysAgo = (n: number) => new Date(now - n * 86_400_000); // The last cardiology visit, and a later routine one of another kind. await db.encounter.create({ data: { userId: owner.id, occurredAt: daysAgo(60), status: "DONE", kind: "SPECIALIST", practitionerId: cardiologist.id, }, }); await db.encounter.create({ data: { userId: owner.id, occurredAt: daysAgo(10), status: "DONE", kind: "ROUTINE", }, }); const next = await db.encounter.create({ data: { userId: owner.id, occurredAt: new Date(now + 7 * 86_400_000), status: "PLANNED", kind: "SPECIALIST", practitionerId: cardiologist.id, }, }); await signIn(owner.id); const { GET: list } = await import("@/app/api/encounters/route"); const visits = await json<{ upcoming: Array<{ id: string; preparation: { from: string } | null }>; }>(await call(list as Handler, "GET", "/api/encounters")); const prepared = visits.upcoming.find((v) => v.id === next.id)!.preparation; expect(prepared).not.toBeNull(); const { GET: notable } = await import("@/app/api/day/notable/route"); const window = await json<{ from: string; anchor: string }>( await call(notable as Handler, "GET", "/api/day/notable"), ); expect(window).toMatchObject({ anchor: "nextVisit", from: prepared!.from }); // With nothing booked, the last completed visit again. await db.encounter.update({ where: { id: next.id }, data: { status: "CANCELLED" }, }); const afterCancel = await json<{ anchor: string }>( await call(notable as Handler, "GET", "/api/day/notable"), ); expect(afterCancel.anchor).toBe("lastVisit"); }); }); describe("timeline", () => { it("lays the record out, lists the standing items and rates the lanes", async () => { const db = getPrismaClient(); const owner = await makeUser("tl", { timeline: true }); const med = await db.medication.create({ data: { userId: owner.id, name: "Metformin", dose: "500 mg" }, }); await db.medicationIntakeEvent.create({ data: { userId: owner.id, medicationId: med.id, scheduledFor: new Date("2026-01-05T07:00:00.000Z"), takenAt: new Date("2026-01-05T07:00:00.000Z"), }, }); await db.medication.create({ data: { userId: owner.id, name: "Never taken", dose: "1" }, }); await db.allergy.create({ data: { userId: owner.id, substance: "Penicillin" }, }); await db.illnessEpisode.create({ data: { userId: owner.id, label: "Flu", type: "INFECTION", onsetAt: new Date("2026-02-01T08:00:00.000Z"), resolvedAt: new Date("2026-02-08T08:00:00.000Z"), }, }); await signIn(owner.id); const { GET } = await import("@/app/api/timeline/route"); const timeline = await json( await call( GET as Handler, "GET", "/api/timeline?zoom=year&to=2026-03-31", ), ); const meds = timeline.lanes.find((l) => l.key === "medications"); expect(meds?.items[0]).toMatchObject({ label: "Metformin", start: "2026-01-05", startKnown: false, open: true, }); expect( timeline.lanes.find((l) => l.key === "illness")?.items[0], ).toMatchObject({ start: "2026-02-01", end: "2026-02-08", kind: "episode", }); expect(timeline.standing.map((s) => s.label).sort()).toEqual([ "Never taken", "Penicillin", ]); expect(timeline.range.dataFrom).toBe("2026-01-05"); const bad = await call(GET as Handler, "GET", "/api/timeline?values=NOPE"); expect(bad.status).toBe(422); const { GET: readiness } = await import("@/app/api/timeline/readiness/route"); const inventory = await json< import("@/lib/day/contract").TimelineReadinessResponse >(await call(readiness as Handler, "GET", "/api/timeline/readiness")); const byKey = Object.fromEntries(inventory.lanes.map((l) => [l.key, l])); expect(byKey.medications.status).toBe("thin"); expect(byKey.medications.gaps[0].href).toMatch( /^\/medications\/.+\?edit=1$/, ); expect(byKey.illness.status).toBe("carries"); expect(byKey.allergies).toMatchObject({ status: "thin", gaps: [] }); expect(byKey.values.status).toBe("empty"); expect(inventory.verdict).toBe("thin"); }); it("averages value series per bucket, counts the readings and leaves gaps out", async () => { const db = getPrismaClient(); const owner = await makeUser("tl-values", { timeline: true }); const reading = (at: string, value: number) => ({ userId: owner.id, type: "BLOOD_PRESSURE_SYS" as const, value, unit: "mmHg", measuredAt: new Date(at), }); await db.measurement.createMany({ data: [ // Two readings on one January day, one on another: three readings, // two days, each day weighing one. reading("2026-01-10T07:00:00.000Z", 120), reading("2026-01-10T19:00:00.000Z", 130), reading("2026-01-20T07:00:00.000Z", 135), // Nothing in February. reading("2026-03-05T07:00:00.000Z", 128), ], }); await signIn(owner.id); const { GET } = await import("@/app/api/timeline/route"); const timeline = await json( await call( GET as Handler, "GET", "/api/timeline?zoom=year&to=2026-03-31&values=BLOOD_PRESSURE_SYS", ), ); expect(timeline.bucket).toBe("month"); expect(timeline.series).toEqual([ { key: "BLOOD_PRESSURE_SYS", unit: "mmHg", points: [ { t: "2026-01-01", mean: 130, count: 3 }, { t: "2026-03-01", mean: 128, count: 1 }, ], }, ]); const weekly = await json( await call( GET as Handler, "GET", "/api/timeline?zoom=quarter&to=2026-03-31&values=BLOOD_PRESSURE_SYS", ), ); expect(weekly.bucket).toBe("week"); expect(weekly.series[0].points.map((p) => [p.t, p.count])).toEqual([ ["2026-01-05", 2], ["2026-01-19", 1], ["2026-03-02", 1], ]); // A chosen range under six weeks averages single days; the two // readings of 10 January stay one day. const daily = await json( await call( GET as Handler, "GET", "/api/timeline?zoom=range&from=2026-01-05&to=2026-01-25&values=BLOOD_PRESSURE_SYS", ), ); expect(daily.zoom).toBe("range"); expect(daily.bucket).toBe("day"); expect(daily.range).toMatchObject({ from: "2026-01-05", to: "2026-01-25" }); expect(daily.series[0].points).toEqual([ { t: "2026-01-10", mean: 125, count: 2 }, { t: "2026-01-20", mean: 135, count: 1 }, ]); // Four months and more: months, as the fixed zooms draw them. const monthly = await json( await call( GET as Handler, "GET", "/api/timeline?zoom=range&from=2025-12-01&to=2026-03-31&values=BLOOD_PRESSURE_SYS", ), ); expect(monthly.bucket).toBe("month"); // A range needs both ends, and spans fifteen years at most. for (const query of [ "zoom=range", "zoom=range&from=2026-01-01", "zoom=range&from=2000-01-01&to=2026-01-01", ]) { expect( (await call(GET as Handler, "GET", `/api/timeline?${query}`)).status, query, ).toBe(422); } // No cap on the count: seven, and an unknown name still refused. const seven = "BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD,BLOOD_GLUCOSE"; expect( (await call(GET as Handler, "GET", `/api/timeline?values=${seven}`)) .status, ).toBe(200); expect( (await call(GET as Handler, "GET", `/api/timeline?values=${seven},NOPE`)) .status, ).toBe(422); }); it("tags every item of one medication with its id, so it draws on one row", async () => { const db = getPrismaClient(); const owner = await makeUser("tl-group", { timeline: true }); const med = await db.medication.create({ data: { userId: owner.id, name: "Mounjaro", dose: "7.5 mg", startsOn: dayKeyAsUtcMidnight("2026-01-05"), }, }); const other = await db.medication.create({ data: { userId: owner.id, name: "Ramipril", dose: "5 mg", startsOn: dayKeyAsUtcMidnight("2025-06-01"), }, }); await db.medicationCourse.create({ data: { userId: owner.id, medicationId: med.id, startsOn: dayKeyAsUtcMidnight("2026-01-05"), }, }); await db.medicationDoseChange.createMany({ data: [ { medicationId: med.id, effectiveFrom: new Date("2026-01-05T08:00:00.000Z"), doseValue: 2.5, doseUnit: "mg", }, { medicationId: med.id, effectiveFrom: new Date("2026-02-02T08:00:00.000Z"), doseValue: 5, doseUnit: "mg", }, ], }); await db.medicationPauseEra.create({ data: { userId: owner.id, medicationId: med.id, pausedAt: new Date("2026-03-01T08:00:00.000Z"), resumedAt: new Date("2026-03-10T08:00:00.000Z"), }, }); await signIn(owner.id); const { GET } = await import("@/app/api/timeline/route"); const timeline = await json( await call( GET as Handler, "GET", "/api/timeline?zoom=year&to=2026-03-31", ), ); const items = timeline.lanes.find((l) => l.key === "medications")!.items; const byGroup = new Map(); for (const i of items) { byGroup.set(i.group, [...(byGroup.get(i.group) ?? []), i.kind]); } // Six items, two medications: Mounjaro's own span, its course, its // two dose changes and its pause all carry its id. expect(items).toHaveLength(6); expect([...byGroup.keys()].sort()).toEqual([med.id, other.id].sort()); expect(byGroup.get(med.id)!.sort()).toEqual( ["course", "doseChange", "doseChange", "medication", "pause"].sort(), ); expect(byGroup.get(other.id)).toEqual(["medication"]); expect( items.find((i) => i.kind === "doseChange" && i.start === "2026-02-02"), ).toMatchObject({ label: "Mounjaro", sub: "5 mg", group: med.id }); }); it("offers every value series the record has, and answers fifteen at once", async () => { const db = getPrismaClient(); const owner = await makeUser("tl-many", { timeline: true, mood: true, glucose: true, recovery: true, }); const types = [ "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT", "PULSE", "BODY_FAT", "BLOOD_GLUCOSE", "ACTIVITY_STEPS", "OXYGEN_SATURATION", "HEART_RATE_VARIABILITY", "BODY_TEMPERATURE", "VO2_MAX", "RESPIRATORY_RATE", "MUSCLE_MASS", "BONE_MASS", ] as const; // A reading a day for a year, every type: about five thousand rows. const rows = []; for (const [i, type] of types.entries()) { for (let d = 0; d < 365; d++) { const at = new Date(Date.UTC(2025, 3, 1 + d, 7, i)); rows.push({ userId: owner.id, type, value: 50 + (d % 30) + i, unit: "x", measuredAt: at, }); } } await db.measurement.createMany({ data: rows }); // An event type is an occurrence, not a value: never offered. await db.measurement.create({ data: { userId: owner.id, type: "HIGH_HEART_RATE_EVENT", value: 1, unit: "count", measuredAt: new Date("2026-01-10T07:00:00.000Z"), }, }); await db.moodEntry.create({ data: { userId: owner.id, date: "2026-01-10", score: 4, mood: "GUT", moodLoggedAt: new Date("2026-01-10T19:00:00.000Z"), }, }); await signIn(owner.id); const { GET } = await import("@/app/api/timeline/route"); const values = [...types, "MOOD"].join(","); expect(values.split(",")).toHaveLength(15); const started = performance.now(); const res = await call( GET as Handler, "GET", `/api/timeline?zoom=year&to=2026-03-31&values=${values}`, ); const took = performance.now() - started; const timeline = await json(res); expect(timeline.series).toHaveLength(15); expect(timeline.series.every((s) => s.points.length > 0)).toBe(true); expect(timeline.availableSeries).toEqual( expect.arrayContaining([...types, "MOOD"]), ); expect(timeline.availableSeries).toHaveLength(15); expect(timeline.availableSeries).not.toContain("HIGH_HEART_RATE_EVENT"); // Fifteen series over a year of daily readings answer in about a tenth // of a second against the test database; the bound is generous so a // slow runner does not fail it, and still catches a read gone quadratic. expect(took).toBeLessThan(5_000); }); it("averages quarters over the years, live before the fold window and rolled up after it", async () => { const db = getPrismaClient(); const owner = await makeUser("tl-quarters", { timeline: true }); const reading = (at: string, value: number) => ({ userId: owner.id, type: "WEIGHT" as const, value, unit: "kg", measuredAt: new Date(at), }); await db.measurement.createMany({ data: [ // Before the five-year fold window: read live. reading("2018-02-10T07:00:00.000Z", 90), reading("2018-03-10T07:00:00.000Z", 88), // Inside it: read from the rollups. January has three days, // February one; the quarter's mean weighs the four days alike. reading("2024-01-10T07:00:00.000Z", 80), reading("2024-01-11T07:00:00.000Z", 80), reading("2024-01-12T07:00:00.000Z", 80), reading("2024-02-10T07:00:00.000Z", 84), // Nothing from April 2024 to March 2025. reading("2025-05-10T07:00:00.000Z", 79), ], }); const { recomputeUserRollups } = await import("@/lib/rollups/measurement-rollups"); await recomputeUserRollups(owner.id, { types: ["WEIGHT"], from: new Date("2018-01-01T00:00:00.000Z"), to: new Date(), }); await signIn(owner.id); const { GET } = await import("@/app/api/timeline/route"); const timeline = await json( await call(GET as Handler, "GET", "/api/timeline?zoom=all&values=WEIGHT"), ); expect(timeline.bucket).toBe("quarter"); expect(timeline.series[0].points).toEqual([ { t: "2018-01-01", mean: 89, count: 2 }, { t: "2024-01-01", mean: 81, count: 4 }, { t: "2025-04-01", mean: 79, count: 1 }, ]); }); }); describe("scores", () => { const DAY = "2026-03-10"; const shift = (key: string, days: number) => new Date(Date.parse(`${key}T12:00:00.000Z`) + days * 86_400_000) .toISOString() .slice(0, 10); /** Ten days of every score up to and including `DAY`. */ async function seedScores(userId: string) { const db = getPrismaClient(); for (let i = 10; i >= 0; i -= 1) { const key = shift(DAY, -i); await db.healthScoreRecord.create({ data: { userId, dayKey: key, timezone: "Europe/Berlin", composite: i === 0 ? 74 : 68 + (i % 3), band: "green", scoreVersion: 1, composition: ["sleep"], pillarScores: { sleep: 70 }, inputFingerprint: "a".repeat(64), }, }); // The nightly proxy is stamped at noon UTC of the day that ended. await db.measurement.create({ data: { userId, type: "RECOVERY_SCORE", value: i === 0 ? 63 : 70 + (i % 4), unit: "score", source: "COMPUTED", measuredAt: new Date(`${shift(key, -1)}T12:00:00.000Z`), }, }); await db.measurement.create({ data: { userId, type: "STRAIN_SCORE", value: i === 0 ? 41 : 30 + (i % 5), unit: "score", source: "COMPUTED", measuredAt: new Date(`${key}T12:00:00.000Z`), }, }); await db.measurement.create({ data: { userId, type: "SLEEP_DURATION", value: 420 + (i % 3) * 15, unit: "min", sleepStage: "ASLEEP", source: "APPLE_HEALTH", measuredAt: new Date(`${key}T05:00:00.000Z`), }, }); } await db.measurement.create({ data: { userId, type: "RECOVERY_SCORE", value: 55, unit: "score", source: "WHOOP", measuredAt: new Date(`${DAY}T06:30:00.000Z`), }, }); } it("shows every score the day holds, on the day it describes", async () => { const owner = await makeUser("scores-all"); await seedScores(owner.id); await signIn(owner.id); const day = await json(await getDay(DAY)); expect(day.scores.map((s) => s.key)).toEqual([ "healthScore", "readiness", "recovery", "sleepScore", "strain", ]); const by = Object.fromEntries(day.scores.map((s) => [s.key, s])); expect(by.healthScore).toMatchObject({ value: 74, max: 100 }); // The proxy stamped on the 9th is the readiness of the 10th. expect(by.readiness).toMatchObject({ value: 63, source: "COMPUTED" }); expect(by.recovery).toMatchObject({ value: 55, source: "WHOOP" }); expect(by.strain).toMatchObject({ value: 41, max: 100 }); expect(by.sleepScore?.value).toBeGreaterThan(0); expect(by.readiness?.band).not.toBeNull(); expect(by.healthScore?.band?.n).toBe(10); // A stored score is not also a reading of the day it is stamped on. expect( day.values.filter((v) => ["RECOVERY_SCORE", "STRAIN_SCORE", "DAY_STRAIN"].includes(v.type), ), ).toEqual([]); }); it("shows a device's day strain alone, on its own scale", async () => { const db = getPrismaClient(); const owner = await makeUser("scores-device"); await db.measurement.create({ data: { userId: owner.id, type: "DAY_STRAIN", value: 12.44, unit: "score", source: "WHOOP", measuredAt: new Date(`${DAY}T07:00:00.000Z`), }, }); await signIn(owner.id); const day = await json(await getDay(DAY)); expect(day.scores).toEqual([ { key: "strain", value: 12.4, max: 21, source: "WHOOP", band: null }, ]); expect(day.values).toEqual([]); }); it("sends no scores for a day without any", async () => { const db = getPrismaClient(); const owner = await makeUser("scores-none"); await db.measurement.create({ data: { userId: owner.id, type: "WEIGHT", value: 80, unit: "kg", measuredAt: new Date(`${DAY}T06:00:00.000Z`), }, }); await signIn(owner.id); const day = await json(await getDay(DAY)); expect(day.scores).toEqual([]); expect(day.sections.scores).toBeUndefined(); }); it("names the scores as not shared under a scoped grant and opens them to a whole-record one", async () => { const owner = await makeUser("scores-share"); await seedScores(owner.id); const scoped = await makeUser("scores-scoped"); await shareWith(owner.id, scoped.id, ["measurements"]); const narrow = await json(await getDay(DAY)); expect(narrow.scores).toEqual([]); expect(narrow.sections.scores).toEqual({ available: false, reason: "not_shared", }); // The stored rows stay readings for a reader who may see them. expect(narrow.values.some((v) => v.type === "RECOVERY_SCORE")).toBe(true); const full = await makeUser("scores-full"); await shareWith(owner.id, full.id, null); const wide = await json(await getDay(DAY)); expect(wide.scores.length).toBe(5); expect(wide.sections.scores).toBeUndefined(); }); it("leaves out the scores of a switched-off module", async () => { const owner = await makeUser("scores-mod", { recovery: false, sleep: false, }); await seedScores(owner.id); await signIn(owner.id); const day = await json(await getDay(DAY)); expect(day.scores.map((s) => s.key)).toEqual(["healthScore"]); expect(day.sections).toEqual({}); }); it("reaches the model as names and numbers", async () => { const owner = await makeUser("scores-model"); await seedScores(owner.id); const { readDayForTool } = await import("@/lib/ai/coach/tools/day-read"); const { UNBOUNDED_REACH } = await import("@/lib/ai/coach/history-reach"); const result = await readDayForTool({ userId: owner.id, date: DAY, reach: UNBOUNDED_REACH, loadExcluded: async () => new Set(["sleep"]), }); expect(result.present).toBe(true); if (!result.present) return; expect(result.data.scores.map((s) => s.score)).toEqual(["strain"]); expect(Object.keys(result.data.scores[0]!).sort()).toEqual([ "band", "max", "score", "value", ]); }); it("measures what the scores add to the day", async () => { const owner = await makeUser("scores-cost"); await seedScores(owner.id); const { loadDay } = await import("@/lib/day/load-day"); const { resolveDayAccess } = await import("@/lib/day/sections"); const access = await resolveDayAccess({ recordId: owner.id, domainVisible: () => true, owner: true, }); const without = { ...access, readable: new Set([...access.readable].filter((s) => s !== "scores")), }; const time = async (a: typeof access) => { const runs: number[] = []; for (let i = 0; i < 7; i += 1) { const t0 = performance.now(); await loadDay({ recordId: owner.id, day: DAY, access: a }); runs.push(performance.now() - t0); } return runs.sort((x, y) => x - y)[3]!; }; await time(access); const base = await time(without); const full = await time(access); // The scores' own reads, alone and in sequence: what they cost even // where nothing runs beside them. const { readDayScores } = await import("@/lib/day/scores"); const alone: number[] = []; for (let i = 0; i < 7; i += 1) { const t0 = performance.now(); await readDayScores({ userId: owner.id, day: DAY, tz: "Europe/Berlin", modules: access.modules, priorityJson: null, }); alone.push(performance.now() - t0); } const scoresAlone = alone.sort((x, y) => x - y)[3]!; process.stdout.write( `[day scores] median loadDay ${base.toFixed(1)} ms without scores, ${full.toFixed(1)} ms with (+${(full - base).toFixed(1)} ms); score reads alone ${scoresAlone.toFixed(1)} ms\n`, ); expect(scoresAlone).toBeLessThan(150); expect(full - base).toBeLessThan(150); }); }); describe("cycle", () => { async function cycleUser(label: string) { const db = getPrismaClient(); const owner = await makeUser(label); await db.cycleProfile.create({ data: { userId: owner.id, cycleTrackingEnabled: true }, }); return owner; } it("states the cycle day and phase of a day inside a closed cycle", async () => { const db = getPrismaClient(); const owner = await cycleUser("cycle-closed"); await db.menstrualCycle.create({ data: { userId: owner.id, startDate: "2026-02-20", endDate: "2026-03-19", periodEndDate: "2026-02-24", tz: "Europe/Berlin", }, }); await db.menstrualCycle.create({ data: { userId: owner.id, startDate: "2026-03-20", tz: "Europe/Berlin" }, }); await signIn(owner.id); const day = await json(await getDay("2026-03-01")); const cycle = day.running.find((r) => r.kind === "cyclePhase"); expect(cycle).toMatchObject({ dayIndex: 10, sub: "FOLLICULAR", since: "2026-02-20", until: "2026-03-19", dayCount: 28, }); const late = await json(await getDay("2026-03-15")); expect(late.running.find((r) => r.kind === "cyclePhase")?.sub).toBe( "LUTEAL", ); }); it("counts the open cycle and stops once it runs past the grace window", async () => { const db = getPrismaClient(); const owner = await cycleUser("cycle-open"); await db.menstrualCycle.create({ data: { userId: owner.id, startDate: "2026-01-02", tz: "Europe/Berlin" }, }); // A forecast row never stands in for a logged cycle. await db.menstrualCycle.create({ data: { userId: owner.id, startDate: "2025-12-01", isPredicted: true, tz: "Europe/Berlin", }, }); await signIn(owner.id); const inside = await json(await getDay("2026-01-04")); expect(inside.running.find((r) => r.kind === "cyclePhase")).toMatchObject({ dayIndex: 3, sub: "MENSTRUAL", until: null, }); const before = await json(await getDay("2025-12-10")); expect(before.running.some((r) => r.kind === "cyclePhase")).toBe(false); // Months later with nothing logged: no "day 90". const stale = await json(await getDay("2026-04-15")); expect(stale.running.some((r) => r.kind === "cyclePhase")).toBe(false); }); it("shows no cycle to an account without cycle tracking", async () => { const db = getPrismaClient(); const owner = await makeUser("cycle-off"); await db.menstrualCycle.create({ data: { userId: owner.id, startDate: "2026-03-01", tz: "Europe/Berlin" }, }); await signIn(owner.id); const day = await json(await getDay("2026-03-05")); expect(day.running.some((r) => r.kind === "cyclePhase")).toBe(false); }); }); describe("performance smoke", () => { it("loads a dense day in well under a second", async () => { const db = getPrismaClient(); const owner = await makeUser("perf"); // Ninety days of minute-level pulse around the day, plus a daily weight. const rows = []; const start = Date.UTC(2026, 0, 1); for (let d = 0; d < 90; d += 1) { for (let m = 0; m < 24 * 60; m += 5) { rows.push({ userId: owner.id, type: "PULSE" as const, value: 60 + (m % 30), unit: "bpm", source: "APPLE_HEALTH" as const, measuredAt: new Date(start + d * 86_400_000 + m * 60_000), }); } rows.push({ userId: owner.id, type: "WEIGHT" as const, value: 80 + (d % 3), unit: "kg", source: "MANUAL" as const, measuredAt: new Date(start + d * 86_400_000 + 7 * 3_600_000), }); } for (let i = 0; i < rows.length; i += 5000) { await db.measurement.createMany({ data: rows.slice(i, i + 5000) }); } await db.$executeRawUnsafe(`ANALYZE "measurements"`); await signIn(owner.id); await getDay("2026-03-15"); const t0 = performance.now(); const response = await getDay("2026-03-15"); const elapsed = performance.now() - t0; expect(response.status).toBe(200); const day = await json(response); expect(day.values.find((v) => v.type === "PULSE")?.band).not.toBeNull(); expect(elapsed).toBeLessThan(1000); }); });