diff --git a/src/lib/daily/__tests__/digest-just-in.test.ts b/src/lib/daily/__tests__/digest-just-in.test.ts index a3b294ee0..ba5b9e9a3 100644 --- a/src/lib/daily/__tests__/digest-just-in.test.ts +++ b/src/lib/daily/__tests__/digest-just-in.test.ts @@ -38,6 +38,7 @@ function meds(): MedsTodayBlock { function input(over: Partial = {}): DailyDigestInput { return { now: NOW, + todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], score: { value: 82, band: "good", delta: 3 }, diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 239b8f55c..68709685d 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -64,6 +64,8 @@ const briefing: DailyBriefing = { function input(over: Partial = {}): DailyDigestInput { return { now: NOW, + // NOW is 09:00Z on 2026-07-16; UTC profile day ends at next midnight. + todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], score: { value: 82, band: "good", delta: 3 }, @@ -383,7 +385,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); - it("shows a weekly future slot once its engine-derived availability window opens", () => { + it("shows a weekly slot due later TODAY once its engine-derived availability window opens", () => { const d = buildDailyDigest( input({ medsToday: meds({ @@ -391,7 +393,8 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { { medicationId: "med-weekly", medicationName: "Weekly dose", - dueAt: new Date(NOW.getTime() + 24 * 60 * 60_000).toISOString(), + // Later today (15:00Z, same local day as NOW). + dueAt: new Date(NOW.getTime() + 6 * 60 * 60_000).toISOString(), overdue: false, availableFrom: new Date(NOW.getTime() - 60_000).toISOString(), }, @@ -410,7 +413,8 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { const candidate = { medicationId: "med-weekly", medicationName: "Weekly dose", - dueAt: new Date(NOW.getTime() + 24 * 60 * 60_000).toISOString(), + // Later today (19:00Z) so only the availability boundary decides. + dueAt: new Date(NOW.getTime() + 10 * 60 * 60_000).toISOString(), overdue: false, availableFrom: NOW.toISOString(), }; @@ -451,6 +455,111 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); + it("keeps a weekly slot due TOMORROW off the rail even when its availability window is open", () => { + // Regression: a weekly/rolling medication with the default window opens + // its availability ~25 h ahead of the anchor (1 day early-days + 60 min + // grace). On Friday the Saturday slot therefore became "available" and + // surfaced as today's pending business. A not-yet-overdue dose that is + // due on a LATER local day is a scheduled event, not today's card. + const d = buildDailyDigest( + input({ + medsToday: meds({ + dueCandidates: [ + { + medicationId: "med-weekly", + medicationName: "Weekly dose", + // Tomorrow 08:00 — outside today's local day. + dueAt: "2026-07-17T08:00:00.000Z", + overdue: false, + // Window already open (now minus 1 h). + availableFrom: new Date( + NOW.getTime() - 60 * 60_000, + ).toISOString(), + }, + ], + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("bounds the rail on the PROFILE-tz day end, exclusive at the boundary instant", () => { + // A Berlin (CEST, UTC+2) profile day ends at 22:00:00Z — the seam passes + // that instant as `todayEndExclusive`, so the builder needs no tz math. + const berlinDayEnd = new Date("2026-07-16T22:00:00.000Z"); + const candidateDue = (dueAtIso: string) => + meds({ + dueCandidates: [ + { + medicationId: "med-weekly", + medicationName: "Weekly dose", + dueAt: dueAtIso, + overdue: false, + availableFrom: new Date(NOW.getTime() - 60 * 60_000).toISOString(), + }, + ], + }); + + // 23:30 Berlin local — still today, stays on the rail. + const lateToday = buildDailyDigest( + input({ + todayEndExclusive: berlinDayEnd, + medsToday: candidateDue("2026-07-16T21:30:00.000Z"), + }), + t, + ); + // 00:30 Berlin local TOMORROW (22:30Z) — off the rail. + const earlyTomorrow = buildDailyDigest( + input({ + todayEndExclusive: berlinDayEnd, + medsToday: candidateDue("2026-07-16T22:30:00.000Z"), + }), + t, + ); + // Exactly local midnight — the bound is exclusive, so this is tomorrow. + const atBoundary = buildDailyDigest( + input({ + todayEndExclusive: berlinDayEnd, + medsToday: candidateDue(berlinDayEnd.toISOString()), + }), + t, + ); + + expect(lateToday.worthALook.some((i) => i.kind === "dose_window")).toBe( + true, + ); + expect(earlyTomorrow.worthALook.some((i) => i.kind === "dose_window")).toBe( + false, + ); + expect(atBoundary.worthALook.some((i) => i.kind === "dose_window")).toBe( + false, + ); + }); + + it("always keeps an OVERDUE candidate on the rail regardless of the day bound", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + dueCandidates: [ + { + medicationId: "med-weekly", + medicationName: "Weekly dose", + // Yesterday — engine says overdue; the day bound never applies. + dueAt: "2026-07-15T08:00:00.000Z", + overdue: true, + availableFrom: "2026-07-14T07:00:00.000Z", + }, + ], + }), + }), + t, + ); + const dose = d.worthALook.find((i) => i.kind === "dose_window"); + expect(dose).toBeDefined(); + expect(dose?.status).toBe("warning"); + }); + it("keeps a stale cached non-overdue scalar calm instead of dropping or escalating it", () => { const d = buildDailyDigest( input({ diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index e51448a84..b98471e6c 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -245,6 +245,15 @@ export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000; /** The fully-resolved, IO-free input the composer folds into a digest. */ export interface DailyDigestInput { now: Date; + /** + * First instant of the user's NEXT local day (profile tz), resolved by the + * IO seam via `getUserTodayBounds`. Bounds the dose-window rail to TODAY'S + * pending business: a weekly/rolling medication's default availability + * window opens ~25 h ahead of its anchor (one early day + the grace + * minutes), so without this bound Friday's rail announced Saturday's slot + * as due. Overdue candidates are never bounded by it. + */ + todayEndExclusive: Date; modules: DigestModuleMap; /** Item kinds allowed to appear in the Today hero rail. */ enabledHeroItemKinds: readonly PriorityItemKind[]; @@ -399,11 +408,18 @@ export const DOSE_DUE_LOOKAHEAD_MS = * * `overdue` is never inferred from the wall clock. A cached non-overdue * candidate whose anchor has passed stays calm/informational until refresh. + * + * A NOT-yet-overdue candidate must also fall due on the user's current local + * day (`dueAt < todayEndExclusive`). The engine's availability window says + * "takeable" — for weekly/rolling schedules that opens up to ~25 h early, and + * takeable-early belongs on the medication card, not on today's rail. An + * overdue candidate always surfaces, whatever day its anchor was. */ function buildDoseWindowItems( meds: MedsTodayBlock, modules: DigestModuleMap, now: Date, + todayEndExclusive: Date, t: Translate, ): PriorityItem[] { if (!moduleEnabled(modules, "medications")) return []; @@ -429,6 +445,10 @@ function buildDoseWindowItems( const dueAt = candidate.dueAt ? Date.parse(candidate.dueAt) : NaN; if (!Number.isFinite(dueAt)) continue; + // Today-only: a dose anchored on a later local day is a scheduled + // event, not today's pending business — however early its window opens. + if (dueAt >= todayEndExclusive.getTime()) continue; + if (candidate.availableFrom !== null) { const availableFrom = Date.parse(candidate.availableFrom); if (!Number.isFinite(availableFrom) || now.getTime() < availableFrom) { @@ -911,7 +931,13 @@ export function buildDailyDigest( // resurfaces on a following day (within its window), never lost. const worthALook: PriorityItem[] = []; worthALook.push( - ...buildDoseWindowItems(input.medsToday, input.modules, input.now, t), + ...buildDoseWindowItems( + input.medsToday, + input.modules, + input.now, + input.todayEndExclusive, + t, + ), ); // A freshly-reached milestone is rare and one-per-day — surface it ahead of // the ambient sync / check-in items so the calm reward is not buried, but diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 5182159d9..7c830bfa9 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -22,6 +22,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { userDayKey } from "@/lib/tz/format"; +import { getUserTodayBounds } from "@/lib/tz/local-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"; @@ -357,6 +358,12 @@ export async function loadDailyDigest( // Computed before the fan-out because the arrival read is keyed on it. Pure // (a tz format of `now`), so hoisting it costs nothing. const todayLocalDate = userDayKey(now, user.timezone); + // 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 [ { body: snapshot, locale }, @@ -607,6 +614,7 @@ export async function loadDailyDigest( const digest = buildDailyDigest( { now, + todayEndExclusive, modules, enabledHeroItemKinds: options.enabledItemKinds ?? diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 312f3bf6d..59c08e10d 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -121,6 +121,7 @@ const NOW = new Date("2026-07-16T09:00:00.000Z"); function degradedDigest() { const input: DailyDigestInput = { now: NOW, + todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], score: { value: 82, band: "good", delta: 3 },