diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 63c6ccf7f..3200e29ea 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import React, { Suspense, useCallback, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -448,6 +449,19 @@ export default function DashboardPageClient({ const rhr = data?.summaries?.RESTING_HEART_RATE; const hasRestingHr = (rhr?.count ?? 0) > 0; const pulseTileSummary = hasRestingHr ? rhr : p; + // The pulse widget shows ONE of two statistics, and its name says which: + // with resting rows it is the resting heart rate (one figure a day, the + // same series the day view names "Resting heart rate"); without them it + // is the pulse, whose latest value is the latest day's pulse (the mean of + // its hours' means, `summaries-slice.ts`), the number the day view shows + // as "Pulse" for that day. Calling the resting figure "Pulse" put 62 on + // the dashboard beside a day view reading 77 for the same name. + const pulseLabel = hasRestingHr + ? t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE) + : t("dashboard.pulseShort"); + const pulseTitle = hasRestingHr + ? t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE) + : t("dashboard.pulse"); const bf = data?.summaries?.BODY_FAT; const sleepSummary = data?.summaries?.SLEEP_DURATION; // v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep @@ -1128,7 +1142,7 @@ export default function DashboardPageClient({ { }); describe("", () => { - it("names the day and counts what it holds", async () => { + it("names the day and carries no count line", async () => { const html = await render(); expect(html).toContain("Saturday, January 3, 2026"); - expect(html).toContain("3 values, 2 entries"); + // The sections say what the day holds; a count above them repeated it. + expect(html).not.toContain("3 values, 2 entries"); + expect(html).not.toContain('data-slot="day-meta"'); }); it("lists what ran through the day with its running count", async () => { @@ -284,8 +286,9 @@ describe("", () => { }, }); expect(html).toContain('data-slot="day-focus"'); - expect(html).toContain("Your 30 days before"); - expect(html).toContain("121–134/78–86"); + // The usual range is the tiles' to draw, not repeated beside the value. + expect(html).not.toContain("Your 30 days before"); + expect(html).not.toContain('data-slot="day-focus-compare"'); expect(html).toContain('data-selected="true"'); expect(html).toContain("Highest daily value since March 2025"); }); diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index 2e8ff7307..943da6d86 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -78,18 +78,13 @@ export const DAY_SECTION_LABEL = /* ─── Focus ───────────────────────────────────────────────────────────── */ -export function DayFocusCard({ - focus, - usual, -}: { - focus: DayFocus; - /** The usual range of the focus type that day, when the server has one. */ - usual: string | null; -}) { - const { t } = useTranslations(); - const compare = - focus.compare ?? - (usual ? { label: t("day.usualLabel"), value: usual } : null); +/** + * The value the person came from. A comparison shows only when the opening + * surface brings one (an earlier lab result); the usual range of the 30 days + * before is not repeated here, because the value tiles below draw it. + */ +export function DayFocusCard({ focus }: { focus: DayFocus }) { + const compare = focus.compare ?? null; return (
!(n.type !== null && focusKeys.has(tileKeyOf(n.type))), ) ?? []; - const focusTile = - focus && data - ? curateDayValues(data.values, focusTypes).curated.find((tile) => - focusKeys.has(tile.key), - ) - : undefined; const isEmpty = data !== undefined && data.values.length === 0 && @@ -178,12 +172,6 @@ export function DayView({ const notShared = data ? Object.values(data.sections).some((s) => s?.reason === "not_shared") : false; - const meta = data - ? [ - tCount("day.countValues", data.counts.values), - tCount("day.countEntries", data.counts.entries), - ].join(", ") - : null; const compact = shell === "bottom"; const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close"); @@ -299,20 +287,9 @@ export function DayView({ compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6", )} > - {/* What the day holds, as meta under the header. The line keeps its - height while the day loads, so nothing below it moves. */} -

- {meta ?? 0} -

{focus ? (
- + { + const types = [...HOURLY_MEAN_DAY_TYPES].filter( + (type) => + slice.summaries[type]?.latest != null && slice.lastSeenByType[type], + ); + if (types.length === 0) return slice; + const [tz, priorityJson] = await Promise.all([ + resolveUserTimezone(userId), + loadUserSourcePriority(userId), + ]); + await Promise.all( + types.map(async (type) => { + const seen = slice.lastSeenByType[type]!; + const day = userDayKey(new Date(seen.lastSeenAt), tz); + const { dayStart, dayEnd } = localDayWindow(day, tz); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: dayStart, lt: dayEnd }, + }, + select: { + type: true, + value: true, + unit: true, + measuredAt: true, + source: true, + deviceType: true, + }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + }); + const canonical = canonicalRowsOfDay(rows, type, day, tz, priorityJson); + const value = dayValue(type, canonical, tz); + if (value !== null) slice.summaries[type].latest = value; + }), + ); + return slice; } /** diff --git a/src/lib/day/__tests__/values.test.ts b/src/lib/day/__tests__/values.test.ts index e6fd91123..25c78d55b 100644 --- a/src/lib/day/__tests__/values.test.ts +++ b/src/lib/day/__tests__/values.test.ts @@ -71,6 +71,7 @@ describe("day values", () => { readings, new Map([["BLOOD_PRESSURE_SYS", band]]), true, + "UTC", ); expect(values.map((v) => [v.type, v.value])).toEqual([ ["SLEEP_DURATION", 420], @@ -80,6 +81,6 @@ describe("day values", () => { ]); expect(values[1].band).toEqual(band); expect(values[3].at).toBe("2026-03-29T20:00:00.000Z"); - expect(shapeDayValues(readings, new Map(), false)).toHaveLength(3); + expect(shapeDayValues(readings, new Map(), false, "UTC")).toHaveLength(3); }); }); diff --git a/src/lib/day/load-day.ts b/src/lib/day/load-day.ts index e969af76c..722f0b9a7 100644 --- a/src/lib/day/load-day.ts +++ b/src/lib/day/load-day.ts @@ -160,7 +160,7 @@ export async function loadDay(args: LoadDayArgs): Promise { }), ]); - const values = shapeDayValues(readings, bands, sleepReadable); + const values = shapeDayValues(readings, bands, sleepReadable, tz); const running = parts.flatMap((p) => p.running).sort(bySince); const events = parts.flatMap((p) => p.events).sort(byTime); diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 43d77110d..0c09f9325 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -126,94 +126,130 @@ function dateKeyOrNull(value: Date | null): string | null { async function medicationsPart(frame: DayFrame): Promise { const { userId, day } = frame; const dayDate = dayKeyAsUtcMidnight(day); - const [meds, courses, pauses, intakes, doseChanges] = await Promise.all([ - prisma.medication.findMany({ - where: { - userId, - asNeeded: false, - oneShot: false, - // Without a start date, the day the medication was entered stands in. - OR: [ - { startsOn: { lte: dayDate } }, - { startsOn: null, createdAt: { lt: frame.dayEnd } }, - ], - AND: [{ OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }] }], - }, - select: { - id: true, - name: true, - dose: true, - active: true, - startsOn: true, - endsOn: true, - createdAt: true, - updatedAt: true, - }, - }), - prisma.medicationCourse.findMany({ - where: { - userId, - startsOn: { lte: dayDate }, - OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }], - }, - select: { - id: true, - medicationId: true, - startsOn: true, - endsOn: true, - medication: { select: { name: true } }, - }, - }), - prisma.medicationPauseEra.findMany({ - where: { - userId, - pausedAt: { lt: frame.dayEnd }, - OR: [{ resumedAt: null }, { resumedAt: { gte: frame.dayStart } }], - }, - select: { - id: true, - medicationId: true, - pausedAt: true, - resumedAt: true, - medication: { select: { name: true } }, - }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { - userId, - deletedAt: null, - skipped: false, - takenAt: { gte: frame.dayStart, lt: frame.dayEnd }, - ...TRACKED_INTAKE_EVENT_WHERE, - }, - select: { - id: true, - medicationId: true, - takenAt: true, - doseTaken: true, - medication: { select: { name: true, dose: true } }, - }, - orderBy: { takenAt: "asc" }, - }), - prisma.medicationDoseChange.findMany({ - where: { - medication: { userId }, - effectiveFrom: { gte: frame.dayStart, lt: frame.dayEnd }, - }, - select: { - id: true, - medicationId: true, - effectiveFrom: true, - doseValue: true, - doseUnit: true, - note: true, - noteEncrypted: true, - medication: { select: { name: true } }, - }, - }), - ]); + const [meds, courses, pauses, intakes, doseChanges, dosesInEffect] = + await Promise.all([ + prisma.medication.findMany({ + where: { + userId, + asNeeded: false, + oneShot: false, + // Without a start date, the day the medication was entered stands in. + OR: [ + { startsOn: { lte: dayDate } }, + { startsOn: null, createdAt: { lt: frame.dayEnd } }, + ], + AND: [{ OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }] }], + }, + select: { + id: true, + name: true, + dose: true, + active: true, + startsOn: true, + endsOn: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.medicationCourse.findMany({ + where: { + userId, + startsOn: { lte: dayDate }, + OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }], + }, + select: { + id: true, + medicationId: true, + startsOn: true, + endsOn: true, + medication: { select: { name: true } }, + }, + }), + prisma.medicationPauseEra.findMany({ + where: { + userId, + pausedAt: { lt: frame.dayEnd }, + OR: [{ resumedAt: null }, { resumedAt: { gte: frame.dayStart } }], + }, + select: { + id: true, + medicationId: true, + pausedAt: true, + resumedAt: true, + medication: { select: { name: true } }, + }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + skipped: false, + takenAt: { gte: frame.dayStart, lt: frame.dayEnd }, + ...TRACKED_INTAKE_EVENT_WHERE, + }, + select: { + id: true, + medicationId: true, + takenAt: true, + doseTaken: true, + medication: { select: { name: true, dose: true } }, + }, + orderBy: { takenAt: "asc" }, + }), + prisma.medicationDoseChange.findMany({ + where: { + medication: { userId }, + effectiveFrom: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + medicationId: true, + effectiveFrom: true, + doseValue: true, + doseUnit: true, + note: true, + noteEncrypted: true, + medication: { select: { name: true } }, + }, + }), + // The dose each medication was taken at that day: its last dose change + // on or before the day. + prisma.medicationDoseChange.findMany({ + where: { medication: { userId }, effectiveFrom: { lt: frame.dayEnd } }, + select: { + medicationId: true, + doseValue: true, + doseUnit: true, + }, + orderBy: { effectiveFrom: "desc" }, + distinct: ["medicationId"], + }), + ]); const out: SectionPart = { running: [], events: [] }; + const doseThatDay = new Map( + dosesInEffect.map((d) => [d.medicationId, `${d.doseValue} ${d.doseUnit}`]), + ); + // One running entry per medication. The medication, its course and its + // pause are three rows in three tables, and listed one by one they read + // as three medications ("Mounjaro 7.5 mg" above "Mounjaro"). The entry is + // the medication under its name and the dose of that day; a pause that + // day makes it the paused entry, a course lends it the course's days. + const runningByMedication = new Map(); + const takeRunning = (medicationId: string, item: DayRunningItem) => { + const held = runningByMedication.get(medicationId); + if (!held) { + runningByMedication.set(medicationId, item); + return; + } + const rank = (r: DayRunningItem) => + r.kind === "medicationPause" ? 2 : r.kind === "medicationCourse" ? 1 : 0; + const [keep, other] = rank(item) > rank(held) ? [item, held] : [held, item]; + runningByMedication.set(medicationId, { + ...keep, + sub: keep.kind === "medicationPause" ? null : (keep.sub ?? other.sub), + }); + }; for (const med of meds) { const since = dateKeyOrNull(med.startsOn); // Without a start date the entry's own date is the earliest the record @@ -227,13 +263,14 @@ async function medicationsPart(frame: DayFrame): Promise { (med.active ? null : userDayKey(med.updatedAt, frame.tz)); if (shownFrom > day || (until !== null && until < day)) continue; const href = `/medications/${med.id}`; - out.running.push( + takeRunning( + med.id, running(frame, { kind: "medication", section: "medications", id: med.id, title: med.name, - sub: med.dose, + sub: doseThatDay.get(med.id) ?? med.dose, since, until, href, @@ -270,13 +307,14 @@ async function medicationsPart(frame: DayFrame): Promise { const since = dateOnlyKey(course.startsOn); const until = dateKeyOrNull(course.endsOn); const href = `/medications/${course.medicationId}`; - out.running.push( + takeRunning( + course.medicationId, running(frame, { kind: "medicationCourse", section: "medications", id: course.id, title: course.medication.name, - sub: null, + sub: doseThatDay.get(course.medicationId) ?? null, since, until, href, @@ -313,7 +351,8 @@ async function medicationsPart(frame: DayFrame): Promise { ? userDayKey(pause.resumedAt, frame.tz) : null; const href = `/medications/${pause.medicationId}`; - out.running.push( + takeRunning( + pause.medicationId, running(frame, { kind: "medicationPause", section: "medications", @@ -387,6 +426,7 @@ async function medicationsPart(frame: DayFrame): Promise { }), ); } + out.running.push(...runningByMedication.values()); return out; } diff --git a/src/lib/day/values.ts b/src/lib/day/values.ts index 5d05b2554..5913a67a6 100644 --- a/src/lib/day/values.ts +++ b/src/lib/day/values.ts @@ -23,6 +23,7 @@ import { readLocalDailyStats } from "@/lib/day/daily-stats"; import { buildBaselineBand } from "@/lib/insights/derived/baseline"; import { HIGH_FREQUENCY_MEAN_TYPES } from "@/lib/measurements/apple-health-mapping"; import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; +import { dayValue } from "@/lib/measurements/day-mean"; import { HOURLY_MEAN_DAY_TYPES } from "@/lib/measurements/day-statistic"; import type { DayReadingRow, DayReadings } from "@/lib/mood/linked-context"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; @@ -51,28 +52,22 @@ export function dayValueShape( return readingCount > MAX_READINGS_PER_TYPE ? "dayValue" : "readings"; } -/** The day value of a type's readings, as `daily-stats.ts` computes it. */ +/** + * The day value of a type's readings, as `daily-stats.ts` computes it: a + * day total for a cumulative type, the mean of the local hours' means for an + * hourly-mean type (pulse, through `day-mean.ts`, the statistic every pulse + * reader shares, so the dashboard's latest pulse and this day's pulse are the + * same number), the plain mean otherwise. + */ export function dayValueOf( type: MeasurementType, rows: readonly DayReadingRow[], + tz: string, ): number { if (isCumulativeDaySumType(type)) { return rows.reduce((sum, r) => sum + r.value, 0); } - if (HOURLY_MEAN_DAY_TYPES.has(type)) { - const hours = new Map(); - for (const r of rows) { - const hour = Math.floor(r.measuredAt.getTime() / 3_600_000); - const cell = hours.get(hour) ?? { total: 0, count: 0 }; - cell.total += r.value; - cell.count += 1; - hours.set(hour, cell); - } - let sum = 0; - for (const cell of hours.values()) sum += cell.total / cell.count; - return sum / hours.size; - } - return rows.reduce((sum, r) => sum + r.value, 0) / rows.length; + return dayValue(type, rows, tz) ?? 0; } /** Round to a precision that keeps a reading readable and a mean honest. */ @@ -88,6 +83,8 @@ export function shapeDayValues( readings: DayReadings, bands: ReadonlyMap, includeSleep: boolean, + /** The record's zone: a pulse day is the mean of its local hours. */ + tz: string, ): DayValue[] { const out: DayValue[] = []; for (const [type, rows] of readings.rowsByType) { @@ -109,7 +106,7 @@ export function shapeDayValues( } else { out.push({ type, - value: tidy(dayValueOf(type, rows)), + value: tidy(dayValueOf(type, rows, tz)), unit: last.unit, at: last.measuredAt.toISOString(), source: last.source, diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts index f3cadc6ce..70381c89a 100644 --- a/src/lib/mood/linked-context.ts +++ b/src/lib/mood/linked-context.ts @@ -160,7 +160,7 @@ interface LinkedRow { * untouched, which is the picker's own pass-through and is correct for a type * no two sources compete over. */ -function canonicalRowsOfDay( +export function canonicalRowsOfDay( rows: readonly LinkedRow[], type: MeasurementType, day: string, diff --git a/tests/integration/dashboard-pulse-latest-day.test.ts b/tests/integration/dashboard-pulse-latest-day.test.ts new file mode 100644 index 000000000..066e55f41 --- /dev/null +++ b/tests/integration/dashboard-pulse-latest-day.test.ts @@ -0,0 +1,125 @@ +/** + * "Pulse" names one statistic on the dashboard and in the day view. + * + * The dashboard's pulse tile led with the latest single reading while the day + * view showed the day's pulse as the mean of its local hours' means. The slim + * summaries slice now reports `latest` of an hourly-mean type as the value of + * its latest local day, through the day view's own canonical-source pick and + * the shared day-mean helper, so the two read the same number for one day. + * + * The day (three weeks back, Europe/Berlin): three readings at 60 in one + * hour, one at 90 in the next, and a last one at 70 an hour later. Hour means + * 60, 90 and 70: the day's pulse is 73.33. The latest reading is 70, the plain + * mean of the five readings 68. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +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), +})); + +const prisma = getPrismaClient(); +const USER = "pulse-latest-day"; +const now = new Date(); +/** UTC midnight three weeks back; 08:00Z is 10:00 in Berlin in any season. */ +const D = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() - 21, +); +const at = (h: number, m: number) => new Date(D + h * 3_600_000 + m * 60_000); +const DAY_KEY = new Date(D).toISOString().slice(0, 10); + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + email: `${USER}@example.test`, + timezone: "Europe/Berlin", + locale: "en", + }, + }); + invalidateUserTimezone(USER); + const readings: Array<[Date, number]> = [ + [at(8, 0), 60], + [at(8, 20), 60], + [at(8, 40), 60], + [at(9, 10), 90], + [at(10, 5), 70], + ]; + await prisma.measurement.createMany({ + data: readings.map(([measuredAt, value], i) => ({ + id: `${USER}-${i}`, + userId: USER, + type: "PULSE", + unit: "bpm", + source: "APPLE_HEALTH", + value, + measuredAt, + })), + }); + const session = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}, 120_000); + +describe("the dashboard's pulse and the day view's pulse", () => { + it("report the latest day's value, not its latest reading", async () => { + const { computeSummariesSlice } = + await import("@/lib/analytics/summaries-slice"); + const slice = await computeSummariesSlice(USER); + expect(slice.summaries.PULSE.latest).toBeCloseTo(220 / 3, 6); + }); + + it("is the number the day view shows for that day", async () => { + const { GET } = await import("@/app/api/day/[date]/route"); + const res = await ( + GET as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ date: string }> }, + ) => Promise + )(new NextRequest(`http://localhost/api/day/${DAY_KEY}`), { + params: Promise.resolve({ date: DAY_KEY }), + }); + expect(res.status).toBe(200); + const day = ( + (await res.json()) as { + data: { values: Array<{ type: string; value: number }> }; + } + ).data; + const pulse = day.values.find((v) => v.type === "PULSE"); + expect(pulse?.value).toBeCloseTo(73.33, 2); + + const { computeSummariesSlice } = + await import("@/lib/analytics/summaries-slice"); + const slice = await computeSummariesSlice(USER); + expect(Math.round(slice.summaries.PULSE.latest! * 100) / 100).toBe( + pulse?.value, + ); + }); +}); diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index 54d24df0d..3486d2907 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -248,6 +248,93 @@ describe("a local day across a clock change", () => { 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");