mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge the final display polish into release/v1.42.0
This commit is contained in:
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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