Bound the Today rail's non-overdue doses to the user's local day

A weekly or rolling medication with the default dose window opens its
availability ~25 h ahead of the anchor (one early day plus the grace
minutes), so the rail announced tomorrow's slot as today's pending
business — on Friday the Saturday dose appeared as due. Thread the
profile-timezone end of today from the digest IO seam into the builder
and keep a not-yet-overdue candidate off the rail unless it falls due
before that instant. Overdue candidates still always surface, and the
early-take affordance on the medication card is untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 09:12:20 +02:00
1 parent 7e4a4d2544
commit 767a2b039b
5 files changed
+149 -4

No files matched your search

@@ -38,6 +38,7 @@ function meds(): MedsTodayBlock {
function input(over: Partial<DailyDigestInput> = {}): 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 },
+112 -3
View File
@@ -64,6 +64,8 @@ const briefing: DailyBriefing = {
function input(over: Partial<DailyDigestInput> = {}): 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({
+27 -1
View File
@@ -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
+8
View File
@@ -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 ??
@@ -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 },