From cd72317356f7660ebdedf92a7f271466dc473314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:16:42 +0200 Subject: [PATCH] fix(day): one entry per medication, one meaning for "Pulse", a quieter panel Running that day listed a medication, its course and its pause as rows of their own, so Mounjaro read as "Mounjaro 7.5 mg" above "Mounjaro". The day read now folds them into one entry per medication: its name with the dose in effect that day (the last dose change on or before it), the course's days on the second line, or the paused entry while a pause runs. The Coach's get_day reads the same loader and gets the same single entry. The dashboard's pulse widget showed the resting heart rate whenever one was recorded but called it "Pulse", while the day view's "Pulse" tile was the day's pulse; one day read 62 on the dashboard and 77 in the day. The widget is now named by what it shows ("Resting heart rate" with resting rows, "Pulse" without), and the pulse's latest value is the latest day's pulse, the mean of its local hours' means through the shared day-mean helper and the day view's own canonical-source pick, so both "Pulse" figures are one number. The day view's pulse goes through the same helper in the record's zone. The count line above the day ("33 values, 3 entries") and the usual range repeated beside the focus value ("Your 30 days before") are gone; the sections and the value tiles already say both. An assessment's score and band join with a comma, not a middle dot. --- src/app/page-client.tsx | 18 +- .../day/__tests__/day-view.test.tsx | 11 +- src/components/day/day-sections.tsx | 21 +- src/components/day/day-view.tsx | 31 +-- src/lib/analytics/summaries-slice.ts | 68 +++++- src/lib/day/__tests__/values.test.ts | 3 +- src/lib/day/load-day.ts | 2 +- src/lib/day/records.ts | 222 +++++++++++------- src/lib/day/values.ts | 29 +-- src/lib/mood/linked-context.ts | 2 +- .../dashboard-pulse-latest-day.test.ts | 125 ++++++++++ tests/integration/day-view.test.ts | 87 +++++++ 12 files changed, 461 insertions(+), 158 deletions(-) create mode 100644 tests/integration/dashboard-pulse-latest-day.test.ts 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");