mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(reminders): one cycle rule for the tick and the start page
The start page read every reminder due before the end of the day, so a daily weigh-in at 20:00 or a twice-daily blood-pressure course showed "check-up due" from the morning on and took a pinned place on the rail. `holdsOpenAfterReminder` is now the one definition of a reminder that stays due after it is sent: an interval longer than seven days, a rule whose next gap is longer than seven days, a rule with no occurrence left, or no cadence; never an appointment. The tick uses it to decide whether a delivered reminder rolls on, and the start page uses it to show such a reminder for its whole due day and keep it under the cap. A short-cycle reminder shows only once its time has come and is not pinned. The tick also skips a reminder already sent for its current slot outside its notify hour before the auto-resolve read. The eventful satisfy worker still resolves it the moment a reading or lab result lands, and the poll still runs for it once a day, in the notify hour; the weekly hold stays after that poll so the safety net is daily.
This commit is contained in:
1 parent
fb960fb72e
commit
31e4c95487
8 files changed
+394
-61
No files matched your search
@@ -1354,7 +1354,10 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
|
||||
it("keeps a due check-up on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, preventiveDue: [{ label: "Skin check" }] }),
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
|
||||
@@ -1363,6 +1366,14 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
});
|
||||
|
||||
it("does not pin a short-cycle reminder: it rolls on anyway", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.map((i) => i.kind)).not.toContain("preventive_care");
|
||||
});
|
||||
|
||||
it("keeps today's visit on a full rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, upcomingVisits: [visit()] }),
|
||||
@@ -1375,7 +1386,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check" }],
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
|
||||
@@ -302,6 +302,22 @@ describe("loadDailyDigest — AI parts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A reminder row as the preventive read selects it; due this morning. */
|
||||
function reminderRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
label: "Skin check",
|
||||
origin: "VORSORGE",
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=YEARLY;INTERVAL=1",
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
nextDueAt: new Date("2026-07-17T07:00:00.000Z"),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -370,8 +386,8 @@ describe("loadDailyDigest — due check-ups and today's visits", () => {
|
||||
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" },
|
||||
reminderRow({ label: "coach.cadence.bp7day.label", origin: "COACH" }),
|
||||
reminderRow({ label: "coach.cadence.bp7day.label" }),
|
||||
] as never);
|
||||
|
||||
await loadDailyDigest(USER, NOW);
|
||||
@@ -380,4 +396,56 @@ describe("loadDailyDigest — due check-ups and today's visits", () => {
|
||||
labels: "Blood pressure week, coach.cadence.bp7day.label",
|
||||
});
|
||||
});
|
||||
|
||||
// Watched red: with every reminder due before the end of the day on the
|
||||
// rail, a daily weigh-in at 20:00 read "check-up due" from the morning on.
|
||||
it("shows a reminder that stays due from the morning of its day", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
|
||||
reminderRow({
|
||||
label: "PHQ-9",
|
||||
measurementType: "PHQ9_SCORE",
|
||||
rrule: null,
|
||||
intervalDays: 14,
|
||||
nextDueAt: new Date("2026-07-17T16:00:00.000Z"), // 18:00 today
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care");
|
||||
});
|
||||
|
||||
it("leaves a short-cycle reminder due later today off until its time", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
|
||||
reminderRow({
|
||||
label: "Weigh-in",
|
||||
measurementType: "WEIGHT",
|
||||
rrule: null,
|
||||
intervalDays: 1,
|
||||
notifyHour: 20,
|
||||
nextDueAt: new Date("2026-07-17T18:00:00.000Z"), // 20:00 today
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(digest.worthALook.map((i) => i.kind)).not.toContain(
|
||||
"preventive_care",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a short-cycle reminder once its time has come", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
|
||||
reminderRow({
|
||||
label: "Weigh-in",
|
||||
rrule: null,
|
||||
intervalDays: 1,
|
||||
nextDueAt: new Date("2026-07-17T06:00:00.000Z"), // 08:00 today
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
|
||||
expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care");
|
||||
});
|
||||
});
|
||||
+13
-5
@@ -142,6 +142,12 @@ export interface DailyDigestSyncIssue {
|
||||
export interface DailyDigestPreventiveDue {
|
||||
/** Display label, already resolved (a Coach cadence key is translated). */
|
||||
label: string;
|
||||
/**
|
||||
* Whether this reminder stays due after it is sent (a cycle longer than a
|
||||
* week, or none). Only such a reminder earns the check-up item a place
|
||||
* under the rail cap; a short-cycle rhythm rolls on by itself.
|
||||
*/
|
||||
staysDue?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -670,8 +676,9 @@ function buildUpcomingVisitItems(
|
||||
/**
|
||||
* 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 cap drops it today: a reminder that stays due and is due or overdue
|
||||
* (its notification has already gone out, and it will not roll on by itself)
|
||||
* 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.
|
||||
*/
|
||||
@@ -1116,9 +1123,10 @@ export function buildDailyDigest(
|
||||
// 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,
|
||||
),
|
||||
[
|
||||
input.preventiveDue.some((due) => due.staysDue) ? preventive : null,
|
||||
visits.today,
|
||||
].filter((item): item is PriorityItem => item !== null),
|
||||
);
|
||||
// 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
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
|
||||
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";
|
||||
@@ -407,10 +408,11 @@ export async function loadDailyDigest(
|
||||
// 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.
|
||||
// Read up to the end of the person's local day; the filter below keeps
|
||||
// what is due now, plus what is due later today when it is a reminder
|
||||
// that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a
|
||||
// reminder keeps its due date after it is sent, so the overdue ones
|
||||
// stay here until they are satisfied, skipped or snoozed.
|
||||
where: {
|
||||
userId: user.id,
|
||||
enabled: true,
|
||||
@@ -418,7 +420,17 @@ export async function loadDailyDigest(
|
||||
origin: { not: "ENCOUNTER" },
|
||||
nextDueAt: { not: null, lt: todayEndExclusive },
|
||||
},
|
||||
select: { label: true, origin: true },
|
||||
select: {
|
||||
label: true,
|
||||
origin: true,
|
||||
intervalDays: true,
|
||||
rrule: true,
|
||||
anchorDate: true,
|
||||
notifyHour: true,
|
||||
lastSatisfiedAt: true,
|
||||
createdAt: true,
|
||||
nextDueAt: true,
|
||||
},
|
||||
orderBy: { nextDueAt: "asc" },
|
||||
take: PREVENTIVE_DUE_READ_LIMIT,
|
||||
}),
|
||||
@@ -637,11 +649,24 @@ export async function loadDailyDigest(
|
||||
// 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 };
|
||||
});
|
||||
//
|
||||
// A reminder that stays due after it is sent (a cycle longer than a week,
|
||||
// a fortnightly questionnaire, a yearly check-up) is on the rail for its
|
||||
// whole due day and keeps its place under the cap. A short-cycle one (a
|
||||
// daily weigh-in, a twice-daily course) is a rhythm: it shows only once its
|
||||
// time has come, is not pinned, and its next reading moves it on.
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = [];
|
||||
for (const row of dueReminders) {
|
||||
if (row.nextDueAt === null) continue;
|
||||
const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt);
|
||||
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
|
||||
let label = row.label;
|
||||
if (row.origin === "COACH") {
|
||||
const translated = t(row.label);
|
||||
if (translated !== row.label) label = translated;
|
||||
}
|
||||
preventiveDue.push({ label, staysDue });
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -878,4 +878,49 @@ describe("runMeasurementReminderTick", () => {
|
||||
// The evening slot of the same day.
|
||||
expect(updates[0].data.nextDueAt).toEqual(new Date("2026-06-15T17:00:00Z"));
|
||||
});
|
||||
|
||||
it("skips an already-reminded open slot outside its hour before any read", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [
|
||||
checkup({
|
||||
nextDueAt: new Date("2026-06-14T07:00:00Z"),
|
||||
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
|
||||
}),
|
||||
],
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
new Date("2026-06-15T12:00:00Z"), // 14:00 Berlin
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.skippedOutsideWindow).toBe(1);
|
||||
expect(prisma.labResult.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still polls an already-reminded open slot for a result in its hour", async () => {
|
||||
const takenAt = new Date("2026-06-14T18:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
checkup({
|
||||
nextDueAt: new Date("2026-06-14T07:00:00Z"),
|
||||
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
|
||||
}),
|
||||
],
|
||||
labMatch: { takenAt },
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.autoResolved).toBe(1);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
* gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A reminder on
|
||||
* a weekly or shorter cycle rolls on to its next slot, which is the
|
||||
* next nudge anyway, and an appointment (`origin: ENCOUNTER`) is
|
||||
* one-shot and closes (see `staysDueAfterReminder`). The per-day claim
|
||||
* one-shot and closes (see `holdsOpenAfterReminder`). The per-day claim
|
||||
* in `notification_events` stays the guard against a second send on
|
||||
* the same local day.
|
||||
* 2. Auto-resolve from an incoming measurement. Before deciding to
|
||||
@@ -57,6 +57,7 @@ import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
|
||||
|
||||
/**
|
||||
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
|
||||
@@ -96,46 +97,9 @@ function moduleForMeasurementType(
|
||||
*/
|
||||
const DUE_QUERY_SLACK_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* Calendar days an open reminder waits before it is sent again, and the cycle
|
||||
* length at or under which a reminder rolls on instead of staying due: a
|
||||
* weekly or daily reminder's own next slot comes round at least as soon as the
|
||||
* repeat would.
|
||||
*/
|
||||
/** Calendar days an open reminder waits before it is sent again. */
|
||||
export const CHECKUP_REPEAT_DAYS = 7;
|
||||
|
||||
/**
|
||||
* v1.39.2 — whether a delivered reminder leaves the row due.
|
||||
*
|
||||
* Decided by the cycle length, not by what the reminder asks for. A reminder
|
||||
* whose next slot is more than a week away stays due until it is satisfied (a
|
||||
* matching reading or lab result, or "done"), skipped or snoozed: rolling it
|
||||
* on would file an open task under a slot weeks or a year away. That held for
|
||||
* free-text check-ups and for measurement reminders alike; the report that
|
||||
* led here was a fortnightly PHQ-9 and GAD-7 pair that each rolled two weeks
|
||||
* forward the morning they were reminded, without either questionnaire being
|
||||
* filled. A one-shot (no interval, no rule) has no next slot and also stays
|
||||
* due. A reminder on a weekly or shorter cycle rolls on as before, because its
|
||||
* next slot is the next reminder anyway; holding a daily course on a missed
|
||||
* slot would stall it. A cadence with no next slot left rolls to nothing as
|
||||
* before. An appointment (`ENCOUNTER`) always stays one-shot.
|
||||
*/
|
||||
export function staysDueAfterReminder(
|
||||
reminder: {
|
||||
origin?: string | null;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
},
|
||||
rolledTo: Date | null,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): boolean {
|
||||
if (reminder.origin === "ENCOUNTER") return false;
|
||||
if (reminder.intervalDays === null && reminder.rrule === null) return true;
|
||||
if (rolledTo === null) return false;
|
||||
return calendarDaysUntil(rolledTo, now, timezone) > CHECKUP_REPEAT_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.2 — whether an open check-up's repeat nudge is still being held.
|
||||
*
|
||||
@@ -322,6 +286,24 @@ export async function runMeasurementReminderTick(
|
||||
try {
|
||||
const timezone = reminder.user.timezone || "Europe/Berlin";
|
||||
|
||||
// v1.39.2 — a reminder already sent for its current slot stays in
|
||||
// this scan every tick until it is satisfied. Outside its notify hour
|
||||
// there is nothing it could do this tick, so skip it before the
|
||||
// auto-resolve read: the eventful `reminder-satisfy` worker resolves it
|
||||
// the moment a reading or lab result lands, and the safety-net poll
|
||||
// below still runs for it once a day, in the notify hour. The weekly
|
||||
// hold stays after that poll on purpose, so the safety net is daily
|
||||
// rather than weekly.
|
||||
if (
|
||||
reminder.lastNotifiedAt != null &&
|
||||
reminder.nextDueAt !== null &&
|
||||
reminder.lastNotifiedAt.getTime() >= reminder.nextDueAt.getTime() &&
|
||||
wallClockInTz(now, timezone).hour !== reminder.notifyHour
|
||||
) {
|
||||
summary.skippedOutsideWindow += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Auto-resolve from an incoming event ────────────────────────
|
||||
// Cheap safety-net poll, in the cron (the eventful `reminder-satisfy`
|
||||
// worker is the fast path). A typed reminder resolves from a matching
|
||||
@@ -485,12 +467,14 @@ export async function runMeasurementReminderTick(
|
||||
// Dispatch succeeded. Record the delivery; a check-up keeps its due
|
||||
// date (it is still open, and `lastNotifiedAt` holds the repeat to a
|
||||
// week), everything else rolls on past this slot.
|
||||
const rolledTo = nextSlotAfterReminder(reminder, timezone, now);
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: staysDueAfterReminder(reminder, rolledTo, timezone, now)
|
||||
data: holdsOpenAfterReminder(reminder, timezone, now)
|
||||
? { lastNotifiedAt: now }
|
||||
: { nextDueAt: rolledTo, lastNotifiedAt: now },
|
||||
: {
|
||||
nextDueAt: nextSlotAfterReminder(reminder, timezone, now),
|
||||
lastNotifiedAt: now,
|
||||
},
|
||||
});
|
||||
summary.dispatched += 1;
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* v1.39.2 — which reminders stay due after they are sent. One predicate for
|
||||
* the reminder tick, the daily digest and the edit route, so the three cannot
|
||||
* disagree about whether a reminder is an open task or a rhythm.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
HOLD_OPEN_MIN_CYCLE_DAYS,
|
||||
holdsOpenAfterReminder,
|
||||
} from "../holds-open";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const SLOT = new Date("2026-09-26T07:00:00.000Z");
|
||||
|
||||
function r(over: Partial<Parameters<typeof holdsOpenAfterReminder>[0]> = {}) {
|
||||
return {
|
||||
origin: "VORSORGE",
|
||||
intervalDays: null as number | null,
|
||||
rrule: null as string | null,
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("holdsOpenAfterReminder", () => {
|
||||
it("holds a cycle longer than a week open", () => {
|
||||
expect(HOLD_OPEN_MIN_CYCLE_DAYS).toBe(7);
|
||||
expect(holdsOpenAfterReminder(r({ intervalDays: 14 }), TZ, SLOT)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(holdsOpenAfterReminder(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a weekly or shorter interval roll on", () => {
|
||||
expect(holdsOpenAfterReminder(r({ intervalDays: 7 }), TZ, SLOT)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(holdsOpenAfterReminder(r({ intervalDays: 1 }), TZ, SLOT)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a rule by the gap to its next occurrence", () => {
|
||||
expect(
|
||||
holdsOpenAfterReminder(r({ rrule: "FREQ=YEARLY;INTERVAL=1" }), TZ, SLOT),
|
||||
).toBe(true);
|
||||
expect(
|
||||
holdsOpenAfterReminder(r({ rrule: "FREQ=MONTHLY;INTERVAL=1" }), TZ, SLOT),
|
||||
).toBe(true);
|
||||
expect(holdsOpenAfterReminder(r({ rrule: "FREQ=WEEKLY" }), TZ, SLOT)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
holdsOpenAfterReminder(r({ rrule: "FREQ=DAILY;BYHOUR=9,19" }), TZ, SLOT),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("holds a rule with no occurrence left open: its last slot is the task", () => {
|
||||
expect(
|
||||
holdsOpenAfterReminder(
|
||||
r({ rrule: "FREQ=DAILY;UNTIL=20260926T235959Z" }),
|
||||
TZ,
|
||||
SLOT,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("holds a reminder with no cadence open", () => {
|
||||
expect(holdsOpenAfterReminder(r(), TZ, SLOT)).toBe(true);
|
||||
});
|
||||
|
||||
it("never holds an appointment", () => {
|
||||
expect(holdsOpenAfterReminder(r({ origin: "ENCOUNTER" }), TZ, SLOT)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
holdsOpenAfterReminder(
|
||||
r({ origin: "ENCOUNTER", intervalDays: 365 }),
|
||||
TZ,
|
||||
SLOT,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("holds the last occurrence open when it is sent", () => {
|
||||
// The tick passes the send instant, a few seconds past the occurrence.
|
||||
expect(
|
||||
holdsOpenAfterReminder(
|
||||
r({ rrule: "FREQ=DAILY;UNTIL=20260926T235959Z" }),
|
||||
TZ,
|
||||
new Date(SLOT.getTime() + 20_000),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("measures the gap that follows the slot, not the one after it", () => {
|
||||
// Twice a month, on the 3rd and on the 1st: from a slot on the 3rd the
|
||||
// next occurrence is four weeks out, and it is that cycle that is open.
|
||||
const rule = r({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1,3" });
|
||||
expect(
|
||||
holdsOpenAfterReminder(rule, TZ, new Date("2026-09-03T07:00:00.000Z")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
holdsOpenAfterReminder(rule, TZ, new Date("2026-09-01T07:00:00.000Z")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* v1.39.2 — whether a reminder stays due after it is sent.
|
||||
*
|
||||
* Decided by the cycle length, not by what the reminder asks for. A reminder
|
||||
* whose next slot is more than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days after the
|
||||
* current one stays due until it is satisfied (a matching reading or lab
|
||||
* result, or "done"), skipped or snoozed: rolling it on would file an open
|
||||
* task under a slot weeks or a year away. That holds for free-text check-ups
|
||||
* and measurement reminders alike; the report that led here was a fortnightly
|
||||
* PHQ-9 and GAD-7 pair. A reminder with no cadence, or a rule with no
|
||||
* occurrence left, has no next slot and also stays due: its last slot is the
|
||||
* task. A weekly or shorter cycle rolls on, because its next slot is the next
|
||||
* reminder anyway, and holding a daily course on a missed slot would stall it.
|
||||
* An appointment (`ENCOUNTER`) is always one-shot.
|
||||
*
|
||||
* The one definition. The reminder tick decides with it whether a delivered
|
||||
* reminder rolls on; the daily digest decides with it whether a reminder due
|
||||
* later today belongs on the start page already and keeps its place there;
|
||||
* the edit route decides with it whether re-enabling keeps the open slot.
|
||||
*
|
||||
* A course window (`endsOn`) is deliberately not consulted: it ends a
|
||||
* Coach-suggested measurement course, which is a short-cycle rhythm and rolls
|
||||
* on regardless.
|
||||
*/
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling";
|
||||
|
||||
/** A cycle of at most this many calendar days rolls on after its reminder. */
|
||||
export const HOLD_OPEN_MIN_CYCLE_DAYS = 7;
|
||||
|
||||
export interface HoldOpenInput {
|
||||
origin?: string | null;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
notifyHour: number;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* `slot` is the instant of the current slot: the send instant in the tick
|
||||
* (a few seconds past the occurrence it sends), the stored `nextDueAt`
|
||||
* elsewhere. For a rule the cycle is the calendar-day gap between its next
|
||||
* two occurrences from `slot` on, so the answer does not depend on where
|
||||
* between two occurrences `slot` sits. With one occurrence left the gap is
|
||||
* measured from `slot` to it; with none left the reminder holds open.
|
||||
*/
|
||||
export function holdsOpenAfterReminder(
|
||||
reminder: HoldOpenInput,
|
||||
timezone: string,
|
||||
slot: Date,
|
||||
): boolean {
|
||||
if (reminder.origin === "ENCOUNTER") return false;
|
||||
if (reminder.intervalDays !== null) {
|
||||
return reminder.intervalDays > HOLD_OPEN_MIN_CYCLE_DAYS;
|
||||
}
|
||||
if (reminder.rrule === null) return true;
|
||||
const input = {
|
||||
intervalDays: null,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: reminder.lastSatisfiedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
// At or after `slot`: a stored slot sits exactly on an occurrence.
|
||||
const first = computeReminderNextDueAt(
|
||||
input,
|
||||
timezone,
|
||||
new Date(slot.getTime() - 1),
|
||||
);
|
||||
if (first === null) return true;
|
||||
const second = computeReminderNextDueAt(input, timezone, first);
|
||||
if (second === null) {
|
||||
// `slot` is the last occurrence: nothing to roll on to.
|
||||
if (first.getTime() <= slot.getTime()) return true;
|
||||
return calendarDaysUntil(first, slot, timezone) > HOLD_OPEN_MIN_CYCLE_DAYS;
|
||||
}
|
||||
return calendarDaysUntil(second, first, timezone) > HOLD_OPEN_MIN_CYCLE_DAYS;
|
||||
}
|
||||
Reference in new issue
Block a user