Merge the final display polish into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 17:25:35 +02:00
commit dcfec1f35d
144 files changed
+4866 -2817

No files matched your search

@@ -21,6 +21,9 @@
* can attribute slow requests to outlier users without re-running
* a DB query.
*/
import { dayValue } from "@/lib/measurements/day-mean";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
@@ -145,13 +148,22 @@ describe("GET /api/analytics — chunked per-type reads (Sr-H1)", () => {
expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope;
// Response shape unchanged: count matches every row inserted, the
// latest value is the final row's value (the last index, i = 5999,
// gives 60 + (5999 % 40) = 60 + 39 = 99). If the cursor skipped or
// duplicated a row across the chunk boundary either of these
// assertions fails loudly.
// Response shape unchanged: count matches every row inserted. The
// latest pulse is the value of its latest local day (the mean of that
// day's hour means, the statistic the day view shows as "Pulse"), so it
// is computed here from the rows of that day through the same helper.
// If the cursor skipped or duplicated a row across the chunk boundary
// the count fails loudly.
expect(envelope.data.summaries.PULSE.count).toBe(ROW_COUNT);
expect(envelope.data.summaries.PULSE.latest).toBe(99);
const tz = await resolveUserTimezone(user.id);
const lastDay = userDayKey(rows.at(-1)!.measuredAt, tz);
const sameDay = rows.filter(
(r) => userDayKey(r.measuredAt, tz) === lastDay,
);
expect(envelope.data.summaries.PULSE.latest).toBeCloseTo(
dayValue("PULSE", sameDay, tz)!,
9,
);
// Wide-event annotation surfaces the row count for slow-query
// attribution. Total reads = 6 000 PULSE rows; every other type
@@ -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,
);
});
});
+87
View File
@@ -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");