mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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 },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user