From 93881c1f93fa8204c5a6631de4958aed09917614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:01:32 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 12 +- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- ...onfig-change-is-not-a-health-event.test.ts | 2 +- src/lib/daily/__tests__/digest.test.ts | 131 ++++++++++++- src/lib/daily/__tests__/load-digest.test.ts | 82 +++++++- src/lib/daily/digest.ts | 180 +++++++++++++----- src/lib/daily/load-digest.ts | 75 +++++--- src/lib/openapi/routes/daily.ts | 2 +- 14 files changed, 420 insertions(+), 99 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3eafcaa06..45b2bcd59 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/messages/de.json b/messages/de.json index 3e1780f8d..06801e37e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 3e5801c35..fa1fb5458 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 025be52d0..46f903102 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 9b79d330c..722b77a24 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index a44deebae..ae8310f2d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 789abfd44..674b6c9a3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index dcea730ae..a2c23d8b3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 07eddcec1..171e54fec 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -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 }, }; diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index acfa1e89c..5283974a8 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -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 = {}) => ({ + 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"]); + }); +}); diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 1b3ac8349..d08d83728 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -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 }; + 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", + }); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 212fdc20f..9441f5344 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -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, + max: number, +): PriorityItem[] { + const keep = new Set(); + 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( + [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, diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index c2de4da55..97c164a58 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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, diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index ad3657d37..64a777261 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -177,7 +177,7 @@ export const dailyPaths: NonNullable = { 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.",