mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
9f3da5ac7d
commit
cd72317356
12 files changed
+461
-158
No files matched your search
+16
-2
@@ -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({
|
||||
<TrendCard
|
||||
key="pulse"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.pulseShort")}
|
||||
label={pulseLabel}
|
||||
latest={pulseTileSummary?.latest ?? null}
|
||||
unit="bpm"
|
||||
avg7={pulseTileSummary?.avg7 ?? null}
|
||||
@@ -1806,7 +1820,7 @@ export default function DashboardPageClient({
|
||||
// rate WITHOUT the resting-band overlay (it would mark
|
||||
// expected-high workout HR as "outside target").
|
||||
types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]}
|
||||
title={t("dashboard.pulse")}
|
||||
title={pulseTitle}
|
||||
colors={["var(--success)"]}
|
||||
unit="bpm"
|
||||
valueBands={hasRestingHr ? pulseBands : undefined}
|
||||
|
||||
@@ -179,10 +179,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("<DayView>", () => {
|
||||
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("<DayView>", () => {
|
||||
},
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="day-focus"
|
||||
@@ -264,7 +259,7 @@ export function dayMeta(
|
||||
: null;
|
||||
const bandKey = bands && band ? keyOf(bands, band) : undefined;
|
||||
const bandText = bandKey ? t(bandKey) : null;
|
||||
return [score, bandText].filter(Boolean).join(" · ") || null;
|
||||
return [score, bandText].filter(Boolean).join(", ") || null;
|
||||
}
|
||||
case "symptom":
|
||||
return t("symptoms.intensityPill", { value: meta });
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
DayValues,
|
||||
} from "./day-sections";
|
||||
import { DayScores } from "./day-scores";
|
||||
import { curateDayValues, tileKeyOf } from "./day-values-model";
|
||||
import { tileKeyOf } from "./day-values-model";
|
||||
import { shiftDateKey } from "./day-url";
|
||||
import { useDay, usePrefetchDay } from "./use-day";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
@@ -124,7 +124,7 @@ export function DayView({
|
||||
above,
|
||||
headerClassName,
|
||||
}: DayViewProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const longLabel = useLongDayLabel();
|
||||
// "Mo., 5. Okt. 2026" in the phone sheet's header: a September Thursday
|
||||
// spelled out in full does not fit beside the arrows at 360 px, and a
|
||||
@@ -132,7 +132,7 @@ export function DayView({
|
||||
const shortLabel = useLongDayLabel("short");
|
||||
const pathname = usePathname();
|
||||
const day = useDay(date);
|
||||
const { labelFor, formatTile } = useDayValueFormat();
|
||||
const { labelFor } = useDayValueFormat();
|
||||
const timelineOn = useModuleEnabled("timeline");
|
||||
const coach = useAiCapability("coach");
|
||||
const capabilities = useRecordCapabilities();
|
||||
@@ -163,12 +163,6 @@ export function DayView({
|
||||
data?.notable.filter(
|
||||
(n) => !(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. */}
|
||||
<p
|
||||
className="text-muted-foreground -mb-3 text-xs tabular-nums"
|
||||
data-slot="day-meta"
|
||||
>
|
||||
{meta ?? <span className="invisible">0</span>}
|
||||
</p>
|
||||
{focus ? (
|
||||
<div className="space-y-2.5">
|
||||
<DayFocusCard
|
||||
focus={focus}
|
||||
usual={focusTile ? formatTile(focusTile).usual : null}
|
||||
/>
|
||||
<DayFocusCard focus={focus} />
|
||||
<DayNotableLines
|
||||
notables={focusNotables}
|
||||
labelFor={labelFor}
|
||||
|
||||
@@ -61,11 +61,13 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
HOURLY_MEAN_DAY_TYPES,
|
||||
usesHourlyMeanDay,
|
||||
windowWeighting,
|
||||
} from "@/lib/measurements/day-statistic";
|
||||
import {
|
||||
SESSION_DAY_FRAME,
|
||||
dayValue,
|
||||
dayWeightedRows,
|
||||
dayWeightedRowsSql,
|
||||
foldMeanSql,
|
||||
@@ -104,6 +106,9 @@ import {
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { canonicalRowsOfDay } from "@/lib/mood/linked-context";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -333,7 +338,10 @@ export async function computeSummariesSlice(
|
||||
// brand-new-type case stays correct.
|
||||
const coverage = precomputedCoverage ?? (await probeRollupCoverage(userId));
|
||||
if (isFullyCovered(coverage)) {
|
||||
return withSleepNightTotals(userId, await computeFromRollups(userId));
|
||||
return withLatestDayValues(
|
||||
userId,
|
||||
await withSleepNightTotals(userId, await computeFromRollups(userId)),
|
||||
);
|
||||
}
|
||||
// v1.4.38.7 — annotate the live fallback with the per-type
|
||||
// coverage map so the operator can see WHICH type stranded the
|
||||
@@ -355,7 +363,63 @@ export async function computeSummariesSlice(
|
||||
},
|
||||
},
|
||||
});
|
||||
return withSleepNightTotals(userId, await computeFromLiveAggregate(userId));
|
||||
return withLatestDayValues(
|
||||
userId,
|
||||
await withSleepNightTotals(userId, await computeFromLiveAggregate(userId)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `latest` of an hourly-mean type (pulse) is the value of its latest local
|
||||
* day, not its latest single reading.
|
||||
*
|
||||
* The dashboard's pulse tile led with the last reading a watch happened to
|
||||
* take, while the day view showed the day's pulse as the mean of its hours'
|
||||
* means (`day-mean.ts`). Both say "Pulse"; both now mean the same statistic:
|
||||
* the latest day's value, read through the same canonical-source pick and
|
||||
* the same day-mean helper the day view uses, in the person's own zone.
|
||||
*/
|
||||
async function withLatestDayValues(
|
||||
userId: string,
|
||||
slice: SummariesSlice,
|
||||
): Promise<SummariesSlice> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -160,7 +160,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
}),
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
+131
-91
@@ -126,94 +126,130 @@ function dateKeyOrNull(value: Date | null): string | null {
|
||||
async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
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<string, DayRunningItem>();
|
||||
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<SectionPart> {
|
||||
(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<SectionPart> {
|
||||
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<SectionPart> {
|
||||
? 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<SectionPart> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
out.running.push(...runningByMedication.values());
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
+13
-16
@@ -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<number, { total: number; count: number }>();
|
||||
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<MeasurementType, DayValue["band"]>,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Response>
|
||||
)(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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Day>(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<Day>(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");
|
||||
|
||||
Reference in new issue
Block a user