mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(daily): keep due check-ups and today's visits on the start page
The start page's short list left out things a reminder had just told the person about. A check-up counted only once its due hour had passed, a booked visit was read one at a time and dropped out at its start time, which is when its reminder fires, and both sat below doses, sync problems and the Coach check-in under a three-item cap, so a busy morning pushed them off. The check-up item now covers every check-up due today or overdue and names up to three of them. Visits are read for the whole local day, also once started, and for the next 48 hours: everything booked today shares one item, the next day with a visit gets a second, and the today / tomorrow / day-after wording follows the calendar rather than an hour count. A due check-up and today's visits always keep a place under the cap; the displayed order is unchanged. A Coach cadence label is translated here as it is on the checkups page. The digest's wire shape is unchanged: visits reach it only as `upcoming_visit` items, which may now appear twice.
This commit is contained in:
1 parent
54b46f4679
commit
93881c1f93
14 files changed
+420
-99
No files matched your search
@@ -18900,10 +18900,14 @@ paths:
|
||||
- Insights
|
||||
summary: The unified daily digest
|
||||
description: "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights
|
||||
cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status
|
||||
and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI
|
||||
gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their
|
||||
capabilities, published in `ai`. Cookie or Bearer auth."
|
||||
cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic
|
||||
integration-status, Vorsorge and visit reads for the 'worth a look' rail. The rail's `preventive_care` item
|
||||
covers every check-up due today or overdue and names up to three of them; `upcoming_visit` items cover every
|
||||
planned visit on the local today (also once it has started, until the day ends) in one item, and the next day
|
||||
with a visit inside the next 48 hours in a second. A due check-up and today's visits always keep a place within
|
||||
the three-item cap. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and
|
||||
the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`.
|
||||
Cookie or Bearer auth."
|
||||
responses:
|
||||
"200":
|
||||
description: The daily digest.
|
||||
|
||||
+3
-2
@@ -10322,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Vorsorge fällig",
|
||||
"body": "{label} ist fällig.",
|
||||
"bodyMany": "{count} Vorsorgetermine sind fällig."
|
||||
"bodyManyNamed": "Fällig: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "Synchronisierung braucht Aufmerksamkeit",
|
||||
@@ -10367,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Termin steht an",
|
||||
"bodyToday": "{what} — heute",
|
||||
"bodyTomorrow": "{what} — morgen"
|
||||
"bodyTomorrow": "{what} — morgen",
|
||||
"bodyDayAfterTomorrow": "{what} — übermorgen"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+3
-2
@@ -10322,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Check-up due",
|
||||
"body": "{label} is due.",
|
||||
"bodyMany": "{count} check-ups are due."
|
||||
"bodyManyNamed": "Due: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "Sync needs attention",
|
||||
@@ -10367,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Appointment coming up",
|
||||
"bodyToday": "{what} — today",
|
||||
"bodyTomorrow": "{what} — tomorrow"
|
||||
"bodyTomorrow": "{what} — tomorrow",
|
||||
"bodyDayAfterTomorrow": "{what} — the day after tomorrow"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+3
-2
@@ -10322,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Revisión pendiente",
|
||||
"body": "{label} está pendiente.",
|
||||
"bodyMany": "Hay {count} revisiones pendientes."
|
||||
"bodyManyNamed": "Pendientes: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "La sincronización necesita atención",
|
||||
@@ -10367,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Cita próxima",
|
||||
"bodyToday": "{what}: hoy",
|
||||
"bodyTomorrow": "{what}: mañana"
|
||||
"bodyTomorrow": "{what}: mañana",
|
||||
"bodyDayAfterTomorrow": "{what}: pasado mañana"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+3
-2
@@ -10322,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Examen à faire",
|
||||
"body": "{label} est à faire.",
|
||||
"bodyMany": "{count} examens sont à faire."
|
||||
"bodyManyNamed": "À faire : {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "La synchronisation demande votre attention",
|
||||
@@ -10367,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Rendez-vous à venir",
|
||||
"bodyToday": "{what} — aujourd'hui",
|
||||
"bodyTomorrow": "{what} — demain"
|
||||
"bodyTomorrow": "{what} — demain",
|
||||
"bodyDayAfterTomorrow": "{what} — après-demain"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+3
-2
@@ -10322,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Controllo da fare",
|
||||
"body": "{label} è da fare.",
|
||||
"bodyMany": "Ci sono {count} controlli da fare."
|
||||
"bodyManyNamed": "Da fare: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "La sincronizzazione richiede attenzione",
|
||||
@@ -10367,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Appuntamento in arrivo",
|
||||
"bodyToday": "{what} — oggi",
|
||||
"bodyTomorrow": "{what} — domani"
|
||||
"bodyTomorrow": "{what} — domani",
|
||||
"bodyDayAfterTomorrow": "{what} — dopodomani"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+3
-2
@@ -10322,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "건강검진 시기",
|
||||
"body": "{label} — 받을 때가 됐어요.",
|
||||
"bodyMany": "건강검진 {count}건을 받을 때가 됐어요."
|
||||
"bodyManyNamed": "받을 때가 된 건강검진: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "동기화 확인이 필요해요",
|
||||
@@ -10367,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "다가오는 예약",
|
||||
"bodyToday": "{what} — 오늘",
|
||||
"bodyTomorrow": "{what} — 내일"
|
||||
"bodyTomorrow": "{what} — 내일",
|
||||
"bodyDayAfterTomorrow": "{what} — 모레"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
+3
-2
@@ -10322,7 +10322,7 @@
|
||||
"preventiveCare": {
|
||||
"title": "Badanie do wykonania",
|
||||
"body": "{label} jest do wykonania.",
|
||||
"bodyMany": "Do wykonania jest {count} badań."
|
||||
"bodyManyNamed": "Do wykonania: {labels}"
|
||||
},
|
||||
"syncIssue": {
|
||||
"title": "Synchronizacja wymaga uwagi",
|
||||
@@ -10367,7 +10367,8 @@
|
||||
"upcomingVisit": {
|
||||
"title": "Zbliża się wizyta",
|
||||
"bodyToday": "{what} — dzisiaj",
|
||||
"bodyTomorrow": "{what} — jutro"
|
||||
"bodyTomorrow": "{what} — jutro",
|
||||
"bodyDayAfterTomorrow": "{what} — pojutrze"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
|
||||
@@ -177,7 +177,7 @@ const fakeDb = vi.hoisted(() => {
|
||||
ecgRecording: { findFirst: async () => null },
|
||||
personalRecord: { findMany: none },
|
||||
arrivalReaction: { findMany: none },
|
||||
encounter: { findFirst: async () => null },
|
||||
encounter: { findMany: none },
|
||||
// The AI capability loader's consent read: no receipts.
|
||||
consentReceipt: { findMany: none },
|
||||
};
|
||||
|
||||
@@ -639,7 +639,18 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
);
|
||||
const care = d.worthALook.filter((i) => i.kind === "preventive_care");
|
||||
expect(care).toHaveLength(1);
|
||||
expect(care[0].body).toBe("3 check-ups are due.");
|
||||
expect(care[0].body).toBe("Due: A, B, C");
|
||||
});
|
||||
|
||||
it("names the first three due check-ups and counts the rest", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
preventiveDue: ["A", "B", "C", "D", "E"].map((label) => ({ label })),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const care = d.worthALook.find((i) => i.kind === "preventive_care");
|
||||
expect(care?.body).toBe("Due: A, B, C +2");
|
||||
});
|
||||
|
||||
it("bounds the rail at three items, never padded", () => {
|
||||
@@ -1317,3 +1328,121 @@ describe("buildDailyDigest — AI text follows its capability", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// v1.39.2 — a check-up that is due today or overdue, and a visit booked for
|
||||
// today, are the two rail items a person can miss for good: the check-up
|
||||
// because the reminder already went out, the visit because the day ends.
|
||||
// Watched red: with the plain `slice(0, 3)` over the priority order, the
|
||||
// first two cases drop the item behind an overdue dose and three sync issues.
|
||||
describe("buildDailyDigest — due check-ups and today's visits hold their place", () => {
|
||||
const crowd = {
|
||||
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
|
||||
syncIssues: [
|
||||
{ integration: "withings", state: "error_reauth" },
|
||||
{ integration: "nightscout", state: "parked" },
|
||||
{ integration: "fitbit", state: "error_reauth" },
|
||||
],
|
||||
};
|
||||
const visit = (over: Record<string, unknown> = {}) => ({
|
||||
id: "v1",
|
||||
kind: "ROUTINE",
|
||||
occurredAt: "2026-07-16T13:00:00.000Z",
|
||||
practitionerName: "Dr. Weiss",
|
||||
dayOffset: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("keeps a due check-up on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, preventiveDue: [{ label: "Skin check" }] }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
|
||||
expect(d.worthALook.map((i) => i.kind)).toContain("preventive_care");
|
||||
// The overdue dose still leads; the pinned item takes a later slot.
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
});
|
||||
|
||||
it("keeps today's visit on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, upcomingVisits: [visit()] }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.map((i) => i.kind)).toContain("upcoming_visit");
|
||||
});
|
||||
|
||||
it("keeps both a due check-up and today's visit on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check" }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.map((i) => i.kind)).toEqual([
|
||||
"dose_window",
|
||||
"preventive_care",
|
||||
"upcoming_visit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not pin a visit that is not today", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, upcomingVisits: [visit({ dayOffset: 1 })] }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.map((i) => i.kind)).not.toContain("upcoming_visit");
|
||||
});
|
||||
|
||||
it("names every visit of the day in one item, including one already under way", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
upcomingVisits: [
|
||||
// Started an hour before NOW: still today's, still on the rail.
|
||||
visit({ occurredAt: "2026-07-16T08:00:00.000Z" }),
|
||||
visit({ id: "v2", practitionerName: "Dentist" }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const visits = d.worthALook.filter((i) => i.kind === "upcoming_visit");
|
||||
expect(visits).toHaveLength(1);
|
||||
expect(visits[0].body).toBe("Dr. Weiss, Dentist — today");
|
||||
});
|
||||
|
||||
it("says tomorrow and the day after by calendar day", () => {
|
||||
const tomorrow = buildDailyDigest(
|
||||
input({ upcomingVisits: [visit({ dayOffset: 1 })] }),
|
||||
t,
|
||||
);
|
||||
expect(
|
||||
tomorrow.worthALook.find((i) => i.kind === "upcoming_visit")?.body,
|
||||
).toBe("Dr. Weiss — tomorrow");
|
||||
const later = buildDailyDigest(
|
||||
input({ upcomingVisits: [visit({ dayOffset: 2 })] }),
|
||||
t,
|
||||
);
|
||||
expect(
|
||||
later.worthALook.find((i) => i.kind === "upcoming_visit")?.body,
|
||||
).toBe("Dr. Weiss — the day after tomorrow");
|
||||
});
|
||||
|
||||
it("shows today's visits and the next day's as two items", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
upcomingVisits: [
|
||||
visit(),
|
||||
visit({ id: "v2", practitionerName: "Dentist", dayOffset: 1 }),
|
||||
visit({ id: "v3", practitionerName: "Lab", dayOffset: 2 }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(
|
||||
d.worthALook
|
||||
.filter((i) => i.kind === "upcoming_visit")
|
||||
.map((i) => i.body),
|
||||
).toEqual(["Dr. Weiss — today", "Dentist — tomorrow"]);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({
|
||||
dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -301,3 +301,83 @@ describe("loadDailyDigest — AI parts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// v1.39.2 — the two reads behind the check-up and visit rail items. NOW is
|
||||
// 11:00 in Berlin on 2026-07-17, so the local day runs 2026-07-16T22:00Z to
|
||||
// 2026-07-17T22:00Z.
|
||||
describe("loadDailyDigest — due check-ups and today's visits", () => {
|
||||
it("reads check-ups due before the end of the local day, not only before now", async () => {
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
const args = vi.mocked(prisma.measurementReminder.findMany).mock
|
||||
.calls[0][0] as { where: Record<string, unknown> };
|
||||
expect(args.where.nextDueAt).toEqual({
|
||||
not: null,
|
||||
lt: new Date("2026-07-17T22:00:00.000Z"),
|
||||
});
|
||||
// Appointments stay off the preventive read.
|
||||
expect(args.where.origin).toEqual({ not: "ENCOUNTER" });
|
||||
});
|
||||
|
||||
it("reads every planned visit from the start of the local day", async () => {
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
const args = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
|
||||
where: { status: string; occurredAt: { gte: Date; lte: Date } };
|
||||
};
|
||||
expect(args.where.status).toBe("PLANNED");
|
||||
expect(args.where.occurredAt.gte).toEqual(
|
||||
new Date("2026-07-16T22:00:00.000Z"),
|
||||
);
|
||||
expect(args.where.occurredAt.lte).toEqual(
|
||||
new Date("2026-07-19T09:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("puts a visit that started this morning on the rail as today's", async () => {
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "v1",
|
||||
kind: "ROUTINE",
|
||||
occurredAt: new Date("2026-07-17T07:00:00.000Z"), // 09:00 Berlin
|
||||
practitioner: { name: "Dr. Weiss" },
|
||||
},
|
||||
{
|
||||
id: "v2",
|
||||
kind: "ROUTINE",
|
||||
occurredAt: new Date("2026-07-17T23:30:00.000Z"), // 01:30 tomorrow
|
||||
practitioner: { name: "Lab" },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
const visits = digest.worthALook.filter(
|
||||
(item) => item.kind === "upcoming_visit",
|
||||
);
|
||||
// The translator is stubbed to echo keys, so the bucket shows in the key.
|
||||
expect(visits.map((item) => item.body)).toEqual([
|
||||
"daily.item.upcomingVisit.bodyToday",
|
||||
"daily.item.upcomingVisit.bodyTomorrow",
|
||||
]);
|
||||
});
|
||||
|
||||
it("translates a Coach cadence label and keeps a free-text one", async () => {
|
||||
const t = vi.fn((key: string) =>
|
||||
key === "coach.cadence.bp7day.label" ? "Blood pressure week" : key,
|
||||
);
|
||||
const { getServerTranslator } =
|
||||
await import("@/lib/i18n/server-translator");
|
||||
vi.mocked(getServerTranslator).mockReturnValueOnce({ t } as never);
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
|
||||
{ label: "coach.cadence.bp7day.label", origin: "COACH" },
|
||||
{ label: "coach.cadence.bp7day.label", origin: "VORSORGE" },
|
||||
] as never);
|
||||
|
||||
await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(t).toHaveBeenCalledWith("daily.item.preventiveCare.bodyManyNamed", {
|
||||
labels: "Blood pressure week, coach.cadence.bp7day.label",
|
||||
});
|
||||
});
|
||||
});
|
||||
+132
-48
@@ -133,13 +133,20 @@ export interface DailyDigestSyncIssue {
|
||||
state: string;
|
||||
}
|
||||
|
||||
/** A Vorsorge / measurement reminder whose next-due instant has passed. */
|
||||
/**
|
||||
* A Vorsorge / measurement reminder that is due today or overdue: its
|
||||
* next-due instant falls before the end of the person's local day. Since
|
||||
* v1.39.2 an open check-up keeps its due date after it is reminded, so this
|
||||
* list holds it until it is done, skipped or snoozed.
|
||||
*/
|
||||
export interface DailyDigestPreventiveDue {
|
||||
/** Display label, already resolved (a Coach cadence key is translated). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked visit falling today or tomorrow.
|
||||
* A booked visit on the person's local today (even once it has started) or
|
||||
* within the next two days.
|
||||
*
|
||||
* Read off the VISIT table, never off the reminder engine: an appointment's
|
||||
* one-shot reminder is excluded from every preventive-care read, and this is
|
||||
@@ -151,6 +158,12 @@ export interface DailyDigestUpcomingVisit {
|
||||
kind: string;
|
||||
occurredAt: string;
|
||||
practitionerName: string | null;
|
||||
/**
|
||||
* Calendar days from the person's local today, in their profile timezone:
|
||||
* 0 today, 1 tomorrow, 2 the day after. Resolved by the IO seam so the
|
||||
* builder never needs the timezone.
|
||||
*/
|
||||
dayOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,8 +314,13 @@ export interface DailyDigestInput {
|
||||
morningRefreshedToday: boolean;
|
||||
syncIssues: DailyDigestSyncIssue[];
|
||||
preventiveDue: DailyDigestPreventiveDue[];
|
||||
/** The booked visit falling today or tomorrow, or null. Honest-absent. */
|
||||
upcomingVisit?: DailyDigestUpcomingVisit | null;
|
||||
/**
|
||||
* Every booked visit on the local today and within the next two days,
|
||||
* soonest first. Optional so a consumer that predates it stays valid; a
|
||||
* missing value means no visit. The digest DTO has no visit field of its
|
||||
* own: visits reach the wire only as `upcoming_visit` rail items.
|
||||
*/
|
||||
upcomingVisits?: readonly DailyDigestUpcomingVisit[];
|
||||
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
|
||||
coachPlans: DailyDigestCoachPlan[];
|
||||
/**
|
||||
@@ -544,16 +562,32 @@ function buildSyncIssueItems(
|
||||
}));
|
||||
}
|
||||
|
||||
/** A single preventive-care item summarising the due Vorsorge reminders. */
|
||||
/** How many due check-ups the rail item names before it counts the rest. */
|
||||
const PREVENTIVE_NAMED_MAX = 3;
|
||||
|
||||
/**
|
||||
* A single preventive-care item summarising the check-ups that are due today
|
||||
* or overdue. One names itself; several are named up to three, with the rest
|
||||
* counted as "+N" so the line needs no plural form in any language.
|
||||
*/
|
||||
function buildPreventiveCareItem(
|
||||
due: DailyDigestPreventiveDue[],
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (due.length === 0) return null;
|
||||
const body =
|
||||
due.length === 1
|
||||
? t("daily.item.preventiveCare.body", { label: due[0].label })
|
||||
: t("daily.item.preventiveCare.bodyMany", { count: due.length });
|
||||
let body: string;
|
||||
if (due.length === 1) {
|
||||
body = t("daily.item.preventiveCare.body", { label: due[0].label });
|
||||
} else {
|
||||
const named = due
|
||||
.slice(0, PREVENTIVE_NAMED_MAX)
|
||||
.map((d) => d.label)
|
||||
.join(", ");
|
||||
const rest = due.length - PREVENTIVE_NAMED_MAX;
|
||||
body = t("daily.item.preventiveCare.bodyManyNamed", {
|
||||
labels: rest > 0 ? `${named} +${rest}` : named,
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "preventive_care",
|
||||
title: t("daily.item.preventiveCare.title"),
|
||||
@@ -570,46 +604,90 @@ function buildPreventiveCareItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* The booked visit falling today or tomorrow, as one rail line.
|
||||
* Booked visits as rail lines: one item for everything booked today, and one
|
||||
* for the next day that has a visit within the two-day horizon.
|
||||
*
|
||||
* Today's item stays for the whole local day, also after the visit's start
|
||||
* time: the visit's reminder fires at that time, and the rail used to drop the
|
||||
* visit at exactly that moment. Several visits on one day share one item and
|
||||
* are named together, so a busy day cannot fill the three-item rail with
|
||||
* appointments alone.
|
||||
*
|
||||
* No module gate: a visit is core, like the checkups page it lives on. No
|
||||
* action beyond opening the page either — there is nothing to do about an
|
||||
* appointment except keep it, and a "mark done" here would duplicate the
|
||||
* visit's own status field with a second place to set it.
|
||||
*/
|
||||
function buildUpcomingVisitItem(
|
||||
visit: DailyDigestUpcomingVisit | null,
|
||||
now: Date,
|
||||
function buildUpcomingVisitItems(
|
||||
visits: readonly DailyDigestUpcomingVisit[],
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!visit) return null;
|
||||
const at = new Date(visit.occurredAt);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
// Today vs tomorrow is decided on the SAME instants the read window used, so
|
||||
// a visit admitted by the query can never be described as neither.
|
||||
const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000);
|
||||
// Through the key resolver, never `encounters.kind.${kind}`: the enum is
|
||||
// `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put
|
||||
// raw dot notation on a lock screen.
|
||||
const what =
|
||||
visit.practitionerName ??
|
||||
t(encounterKindLabelKey(visit.kind as EncounterKind));
|
||||
return {
|
||||
kind: "upcoming_visit",
|
||||
title: t("daily.item.upcomingVisit.title"),
|
||||
body:
|
||||
hoursAway <= 24
|
||||
? t("daily.item.upcomingVisit.bodyToday", { what })
|
||||
: t("daily.item.upcomingVisit.bodyTomorrow", { what }),
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
): { today: PriorityItem | null; next: PriorityItem | null } {
|
||||
const item = (group: readonly DailyDigestUpcomingVisit[]): PriorityItem => {
|
||||
// Through the key resolver, never `encounters.kind.${kind}`: the enum is
|
||||
// `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put
|
||||
// raw dot notation on a lock screen.
|
||||
const names = group.map(
|
||||
(visit) =>
|
||||
visit.practitionerName ??
|
||||
t(encounterKindLabelKey(visit.kind as EncounterKind)),
|
||||
);
|
||||
const what = Array.from(new Set(names)).join(", ");
|
||||
const offset = group[0].dayOffset;
|
||||
return {
|
||||
kind: "upcoming_visit",
|
||||
title: t("daily.item.upcomingVisit.title"),
|
||||
body:
|
||||
offset <= 0
|
||||
? t("daily.item.upcomingVisit.bodyToday", { what })
|
||||
: offset === 1
|
||||
? t("daily.item.upcomingVisit.bodyTomorrow", { what })
|
||||
: t("daily.item.upcomingVisit.bodyDayAfterTomorrow", { what }),
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const valid = visits.filter(
|
||||
(visit) => !Number.isNaN(new Date(visit.occurredAt).getTime()),
|
||||
);
|
||||
const today = valid.filter((visit) => visit.dayOffset <= 0);
|
||||
const later = valid.filter((visit) => visit.dayOffset > 0);
|
||||
const nextOffset =
|
||||
later.length > 0 ? Math.min(...later.map((v) => v.dayOffset)) : null;
|
||||
const next = later.filter((visit) => visit.dayOffset === nextOffset);
|
||||
return {
|
||||
today: today.length > 0 ? item(today) : null,
|
||||
next: next.length > 0 ? item(next) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounded rail: at most `max` items, in priority order, with the pinned
|
||||
* items guaranteed a place. A pinned item is one a person can miss for good if
|
||||
* the cap drops it today: a check-up that is due or overdue (its reminder has
|
||||
* already gone out) and a visit booked for today (the day ends). The rest fill
|
||||
* the remaining slots in order, and the result keeps the priority order, so an
|
||||
* overdue dose still leads.
|
||||
*/
|
||||
function capWithPinned(
|
||||
items: readonly PriorityItem[],
|
||||
pinned: ReadonlySet<PriorityItem>,
|
||||
max: number,
|
||||
): PriorityItem[] {
|
||||
const keep = new Set<PriorityItem>();
|
||||
for (const item of items) {
|
||||
if (keep.size < max && pinned.has(item)) keep.add(item);
|
||||
}
|
||||
for (const item of items) {
|
||||
if (keep.size < max) keep.add(item);
|
||||
}
|
||||
return items.filter((item) => keep.has(item));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -993,7 +1071,9 @@ export function buildDailyDigest(
|
||||
// Priority order: a due or overdue dose is the most time-sensitive daily
|
||||
// action, a broken sync next, then the calm coach check-in, a preventive
|
||||
// check-up least urgent. Bounded to 3 — a check-in the cap crowds out
|
||||
// resurfaces on a following day (within its window), never lost.
|
||||
// resurfaces on a following day (within its window), never lost. A due
|
||||
// check-up and today's visits are not left to the order: the cap keeps
|
||||
// them (see `capWithPinned`).
|
||||
const worthALook: PriorityItem[] = [];
|
||||
worthALook.push(
|
||||
...buildDoseWindowItems(
|
||||
@@ -1030,12 +1110,16 @@ export function buildDailyDigest(
|
||||
// rail because a person reads "what am I meant to arrange" and "where am I
|
||||
// meant to be" in one glance, and they stay separate items because one is
|
||||
// overdue and the other is simply scheduled.
|
||||
const visit = buildUpcomingVisitItem(
|
||||
input.upcomingVisit ?? null,
|
||||
input.now,
|
||||
t,
|
||||
const visits = buildUpcomingVisitItems(input.upcomingVisits ?? [], t);
|
||||
if (visits.today) worthALook.push(visits.today);
|
||||
if (visits.next) worthALook.push(visits.next);
|
||||
// Due check-ups and today's visits keep their place under the cap (see
|
||||
// `capWithPinned`); they still sit below a dose in the displayed order.
|
||||
const pinned = new Set<PriorityItem>(
|
||||
[preventive, visits.today].filter(
|
||||
(item): item is PriorityItem => item !== null,
|
||||
),
|
||||
);
|
||||
if (visit) worthALook.push(visit);
|
||||
// S11 — the calm, informational tension marker sits last: it is context, not
|
||||
// an action that expires, so a time-sensitive dose / sync / check-in wins the
|
||||
// bounded rail ahead of it.
|
||||
@@ -1086,7 +1170,7 @@ export function buildDailyDigest(
|
||||
topSignal,
|
||||
briefingLead,
|
||||
line: composeLine(briefingLead, topSignal, input.score, t),
|
||||
worthALook: visible.slice(0, MAX_WORTH_A_LOOK),
|
||||
worthALook: capWithPinned(visible, pinned, MAX_WORTH_A_LOOK),
|
||||
justIn,
|
||||
reactionLine,
|
||||
ai: input.ai,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import { isArrivalKind } from "@/lib/arrivals/types";
|
||||
@@ -71,7 +72,8 @@ import {
|
||||
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
|
||||
/**
|
||||
* How far ahead the rail mentions a booked appointment: today or tomorrow.
|
||||
* How far ahead the rail mentions a booked appointment: the next 48 hours, on
|
||||
* top of everything booked for the local today.
|
||||
*
|
||||
* The rail is a "what about today" surface, so anything further out is not a
|
||||
* thing to act on this morning. Two days rather than one because an evening
|
||||
@@ -80,6 +82,9 @@ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
*/
|
||||
const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Defensive cap on how many planned visits the rail read returns. */
|
||||
const UPCOMING_VISIT_READ_LIMIT = 10;
|
||||
|
||||
/** Defensive cap on how many overdue reminders we read for the rail summary. */
|
||||
const PREVENTIVE_DUE_READ_LIMIT = 20;
|
||||
|
||||
@@ -372,9 +377,8 @@ export async function loadDailyDigest(
|
||||
// First instant of the user's NEXT local day (profile tz) — bounds the
|
||||
// dose-window rail to today's business. `getUserTodayBounds` returns the
|
||||
// inclusive last millisecond of today; +1 ms is exactly tomorrow's start.
|
||||
const todayEndExclusive = new Date(
|
||||
getUserTodayBounds(now, user.timezone).end.getTime() + 1,
|
||||
);
|
||||
const todayBounds = getUserTodayBounds(now, user.timezone);
|
||||
const todayEndExclusive = new Date(todayBounds.end.getTime() + 1);
|
||||
|
||||
const [
|
||||
{ body: snapshot, locale },
|
||||
@@ -384,7 +388,7 @@ export async function loadDailyDigest(
|
||||
planRows,
|
||||
ecgRow,
|
||||
arrivalRows,
|
||||
nextVisitRow,
|
||||
visitRows,
|
||||
coachAi,
|
||||
reactionLinesAi,
|
||||
] = await Promise.all([
|
||||
@@ -402,14 +406,19 @@ export async function loadDailyDigest(
|
||||
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
|
||||
// proves every one of them, and the DTO mapper refuses such a row
|
||||
// outright so a site that lost its filter fails loudly.
|
||||
//
|
||||
// Due TODAY or overdue: anything whose due instant falls before the end
|
||||
// of the person's local day, not only what has passed by now. Since
|
||||
// v1.39.2 an open check-up keeps its due date after the reminder, so
|
||||
// the overdue ones stay here until they are done, skipped or snoozed.
|
||||
where: {
|
||||
userId: user.id,
|
||||
enabled: true,
|
||||
deletedAt: null,
|
||||
origin: { not: "ENCOUNTER" },
|
||||
nextDueAt: { not: null, lte: now },
|
||||
nextDueAt: { not: null, lt: todayEndExclusive },
|
||||
},
|
||||
select: { label: true },
|
||||
select: { label: true, origin: true },
|
||||
orderBy: { nextDueAt: "asc" },
|
||||
take: PREVENTIVE_DUE_READ_LIMIT,
|
||||
}),
|
||||
@@ -460,20 +469,22 @@ export async function loadDailyDigest(
|
||||
generatedAt: true,
|
||||
},
|
||||
}),
|
||||
// The next PLANNED visit inside the two-day horizon. Read straight off the
|
||||
// visit table, never off the reminder engine — see the mapping below for
|
||||
// why the exclusion above must stay untouched.
|
||||
prisma.encounter.findFirst({
|
||||
// Every PLANNED visit from the start of the local today to the end of the
|
||||
// two-day horizon. Read straight off the visit table, never off the
|
||||
// reminder engine — see the mapping below for why the exclusion above
|
||||
// must stay untouched.
|
||||
prisma.encounter.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: "PLANNED",
|
||||
occurredAt: {
|
||||
gt: now,
|
||||
gte: todayBounds.start,
|
||||
lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS),
|
||||
},
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
take: UPCOMING_VISIT_READ_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
@@ -531,12 +542,9 @@ export async function loadDailyDigest(
|
||||
state: row.state,
|
||||
}));
|
||||
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => ({
|
||||
label: row.label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The appointment falling today or tomorrow, read from the VISIT table.
|
||||
* The booked visits of the local today and the next 48 hours, read from the
|
||||
* VISIT table.
|
||||
*
|
||||
* Not from the reminder table. An ENCOUNTER-origin reminder is excluded from
|
||||
* every preventive-care read, which is right — an appointment is not a
|
||||
@@ -545,18 +553,18 @@ export async function loadDailyDigest(
|
||||
* in, and the exclusion above stays exactly as it is. A diff here that
|
||||
* touches `origin` is the wrong fix.
|
||||
*
|
||||
* `occurredAt > now` deliberately, not `>= todayStart`: an appointment that
|
||||
* has already happened today is not something to be reminded about, and the
|
||||
* person is more likely to be at it than looking at this.
|
||||
* `occurredAt >= todayStart`, not `> now`: a visit's reminder fires at its
|
||||
* start time, and a rail that dropped the visit at that very moment showed a
|
||||
* notification about something the start page no longer mentioned. A visit
|
||||
* that is still PLANNED stays on the rail until its day ends.
|
||||
*/
|
||||
const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow
|
||||
? {
|
||||
id: nextVisitRow.id,
|
||||
kind: nextVisitRow.kind,
|
||||
occurredAt: nextVisitRow.occurredAt.toISOString(),
|
||||
practitionerName: nextVisitRow.practitioner?.name ?? null,
|
||||
}
|
||||
: null;
|
||||
const upcomingVisits: DailyDigestUpcomingVisit[] = visitRows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
dayOffset: calendarDaysUntil(row.occurredAt, now, user.timezone),
|
||||
}));
|
||||
|
||||
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
|
||||
// builder decides "new" and gates on the `insights` module. An ECG row only
|
||||
@@ -626,6 +634,15 @@ export async function loadDailyDigest(
|
||||
const resolvedLocale: Locale = locale;
|
||||
const { t } = getServerTranslator(resolvedLocale);
|
||||
|
||||
// A Coach-suggested measurement cadence stores its label as a bundle key;
|
||||
// the checkups page and the dashboard card translate it, and so does this
|
||||
// line. A free-text label (every other row) is the person's own words.
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => {
|
||||
if (row.origin !== "COACH") return { label: row.label };
|
||||
const translated = t(row.label);
|
||||
return { label: translated === row.label ? row.label : translated };
|
||||
});
|
||||
|
||||
// Group the two figures for the reader. `Intl.NumberFormat` is the only
|
||||
// locale-aware step the digest takes on its own; everything else it renders
|
||||
// comes through the translator.
|
||||
@@ -655,7 +672,7 @@ export async function loadDailyDigest(
|
||||
morningRefreshedToday,
|
||||
syncIssues,
|
||||
preventiveDue,
|
||||
upcomingVisit,
|
||||
upcomingVisits,
|
||||
coachPlans,
|
||||
milestone,
|
||||
tensionWindow,
|
||||
|
||||
@@ -177,7 +177,7 @@ export const dailyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "The unified daily digest",
|
||||
description:
|
||||
"Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth.",
|
||||
"Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status, Vorsorge and visit reads for the 'worth a look' rail. The rail's `preventive_care` item covers every check-up due today or overdue and names up to three of them; `upcoming_visit` items cover every planned visit on the local today (also once it has started, until the day ends) in one item, and the next day with a visit inside the next 48 hours in a second. A due check-up and today's visits always keep a place within the three-item cap. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The daily digest.",
|
||||
|
||||
Reference in new issue
Block a user