mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The hero led with the first sentence of the cached briefing, which was
often a greeting ("Good morning.") while the sentence with content was
never shown. Without an AI provider it fell back to "Your health score
today is N.", which the hero then removed because the ring already
shows the number, leaving a ring and "Nothing needs your attention".
The daily digest now publishes the lead resolved on the server and a
short list of facts about the day:
- lead: the reaction line or the first briefing sentence that is not a
greeting and does not repeat the score while AI text is available,
otherwise a deterministic sentence about the strongest signal (a vital
outside its personal range, a night an hour off the usual, vitals in
range, last night). A fact the lead was built from leaves the list.
- today: up to five statements in priority order (Rest Mode, doses taken
today, an appointment today or tomorrow that is not already on the
rail, last night's sleep against the usual, vitals against their
personal range, cycle day). Module-gated on the server, never padded,
never asking for anything. A phone shows the first four.
- restMode: passed through while an illness episode is active.
- score.steadyWeeks: how long the score has held, read off the stored
daily scores, shown under the ring in place of a delta.
briefingLead skips greetings and score sentences the same way, so the
morning push improves with it. Visit times are rendered in the profile
timezone and hour-cycle preference; the cycle day comes from the same
verdict the cycle page uses. The ring, the rail and the charts are
unchanged. OpenAPI regenerated; additive fields only.
225 lines
7.1 KiB
TypeScript
225 lines
7.1 KiB
TypeScript
/**
|
|
* The Today overview on the daily digest, against real Postgres.
|
|
*
|
|
* The unit suites pin the selection rules on hand-built inputs. This one runs
|
|
* the whole read path (the dashboard snapshot, the coincident-deviation
|
|
* engine, the visit and episode tables, the cycle grid, the stored daily
|
|
* scores) on seeded rows, and checks that what comes out is a valid
|
|
* `DailyDigest` under the published OpenAPI schema. An account without any
|
|
* AI provider is the subject on purpose: the overview has to be whole
|
|
* without one.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
|
import { dailyDigestResponse } from "@/lib/openapi/routes/daily";
|
|
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
|
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const TZ = "Europe/Berlin";
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
// 08:00 in Berlin on a Sunday morning.
|
|
const NOW = new Date("2026-10-04T06:00:00.000Z");
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
__resetAllCachesForTests();
|
|
});
|
|
|
|
async function seedUser(
|
|
id: string,
|
|
extra: Partial<Prisma.UserCreateInput> = {},
|
|
) {
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
id,
|
|
username: id,
|
|
email: `${id}@example.test`,
|
|
timezone: TZ,
|
|
gender: "FEMALE",
|
|
dateOfBirth: new Date("1985-05-05T00:00:00.000Z"),
|
|
...extra,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** A month of steady resting heart rate and HRV, then an elevated morning. */
|
|
async function seedVitals(userId: string) {
|
|
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
|
for (let d = 30; d >= 1; d--) {
|
|
const at = new Date(NOW.getTime() - d * DAY);
|
|
rows.push(
|
|
{
|
|
userId,
|
|
type: "RESTING_HEART_RATE",
|
|
value: 52 + (d % 3),
|
|
unit: "bpm",
|
|
measuredAt: at,
|
|
source: "MANUAL",
|
|
},
|
|
{
|
|
userId,
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 48 + (d % 4),
|
|
unit: "ms",
|
|
measuredAt: at,
|
|
source: "MANUAL",
|
|
},
|
|
);
|
|
}
|
|
rows.push(
|
|
{
|
|
userId,
|
|
type: "RESTING_HEART_RATE",
|
|
value: 66,
|
|
unit: "bpm",
|
|
measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000),
|
|
source: "MANUAL",
|
|
},
|
|
{
|
|
userId,
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 49,
|
|
unit: "ms",
|
|
measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000),
|
|
source: "MANUAL",
|
|
},
|
|
);
|
|
await getPrismaClient().measurement.createMany({ data: rows });
|
|
}
|
|
|
|
async function seedDay(userId: string, cycleTrackingEnabled?: boolean) {
|
|
const prisma = getPrismaClient();
|
|
await seedVitals(userId);
|
|
// Unwell since Friday evening, Berlin time: day 3 on Sunday.
|
|
await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId,
|
|
label: "Cold",
|
|
type: "INFECTION",
|
|
onsetAt: new Date("2026-10-02T18:00:00.000Z"),
|
|
},
|
|
});
|
|
// Tomorrow 09:30 in Berlin.
|
|
await prisma.encounter.create({
|
|
data: {
|
|
userId,
|
|
occurredAt: new Date("2026-10-05T07:30:00.000Z"),
|
|
status: "PLANNED",
|
|
kind: "ROUTINE",
|
|
},
|
|
});
|
|
// One logged period starting eleven days ago: day 12 today, phase still
|
|
// withheld while the engine learns.
|
|
await prisma.cycleProfile.create({ data: { userId, cycleTrackingEnabled } });
|
|
await prisma.menstrualCycle.create({
|
|
data: {
|
|
userId,
|
|
startDate: "2026-09-23",
|
|
periodEndDate: "2026-09-27",
|
|
tz: TZ,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("the Today overview on a real record", () => {
|
|
it("publishes a valid digest with a deterministic lead and module-gated facts", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("today-owner");
|
|
await seedDay(user.id);
|
|
const full = await prisma.user.findUniqueOrThrow({
|
|
where: { id: user.id },
|
|
});
|
|
|
|
const digest = await loadDailyDigest(full, NOW, { locale: "en" });
|
|
|
|
// The published contract holds, new fields included.
|
|
const parsed = dailyDigestResponse.safeParse(
|
|
JSON.parse(JSON.stringify(digest)),
|
|
);
|
|
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
|
|
|
|
// No AI provider: no briefing text, and the lead is built from the
|
|
// morning's elevated resting heart rate.
|
|
expect(digest.briefingLead).toBeNull();
|
|
expect(digest.lead?.source).toBe("signal");
|
|
expect(digest.lead?.text).toMatch(
|
|
/^Resting heart rate is at 66 bpm, above your usual range of \d+ to \d+ bpm\.$/,
|
|
);
|
|
|
|
// Rest Mode passes through, counted on the Berlin calendar.
|
|
expect(digest.restMode).toEqual({ day: 3 });
|
|
|
|
// Tomorrow's visit is on the rail, so it is not repeated under Today;
|
|
// the vitals line became the lead, so it is not repeated either.
|
|
expect(digest.worthALook.some((i) => i.kind === "upcoming_visit")).toBe(
|
|
true,
|
|
);
|
|
expect(digest.today.map((f) => f.kind)).toEqual(["rest_mode", "cycle"]);
|
|
expect(digest.today[0].value).toBe("Day 3");
|
|
expect(digest.today[1].value).toBe("Day 12");
|
|
});
|
|
|
|
it("names the visit under Today when the rail does not carry it", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("today-norail");
|
|
await seedDay(user.id);
|
|
const full = await prisma.user.findUniqueOrThrow({
|
|
where: { id: user.id },
|
|
});
|
|
|
|
const digest = await loadDailyDigest(full, NOW, {
|
|
locale: "en",
|
|
enabledItemKinds: PRIORITY_ITEM_KINDS.filter(
|
|
(kind) => kind !== "upcoming_visit",
|
|
),
|
|
});
|
|
const appointment = digest.today.find((f) => f.kind === "appointment");
|
|
expect(appointment?.value).toBe("Tomorrow 09:30 AM, Routine visit");
|
|
});
|
|
|
|
it("says nothing about switched-off modules", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("today-modules-off", {
|
|
modulePreferencesJson: { illness: false },
|
|
});
|
|
// The cycle module follows the cycle profile's own switch, not the
|
|
// module preference blob.
|
|
await seedDay(user.id, false);
|
|
const full = await prisma.user.findUniqueOrThrow({
|
|
where: { id: user.id },
|
|
});
|
|
|
|
const digest = await loadDailyDigest(full, NOW, { locale: "en" });
|
|
expect(digest.restMode).toBeNull();
|
|
expect(digest.today.some((f) => f.kind === "rest_mode")).toBe(false);
|
|
expect(digest.today.some((f) => f.kind === "cycle")).toBe(false);
|
|
expect(
|
|
dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("stays valid and empty for an account with nothing recorded", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("today-empty");
|
|
const full = await prisma.user.findUniqueOrThrow({
|
|
where: { id: user.id },
|
|
});
|
|
|
|
const digest = await loadDailyDigest(full, NOW, { locale: "en" });
|
|
expect(digest.lead).toBeNull();
|
|
expect(digest.today).toEqual([]);
|
|
expect(digest.restMode).toBeNull();
|
|
expect(
|
|
dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success,
|
|
).toBe(true);
|
|
});
|
|
});
|