Phase 11: Per-Event Reminders (CAL-13/14, NOTIF-04/05/06) #19

Merged
luckberg merged 41 commits from gsd/phase-11-per-event-reminders into main 2026-06-14 14:06:45 -04:00
Showing only changes of commit 16ac235476 - Show all commits
+18 -2
View File
@@ -79,11 +79,23 @@ function yyyyMmDd(d: Date): string {
/**
* Humanize a lead time in minutes to a human-readable string.
*
* CR-02 (Phase 11 Plan 05): added isAllDay parameter. All-day events use day-granularity
* wording ("Today", "Tomorrow", "In N days/1 week") because the lead is day-based
* and "Starts in 0 min" for a same-day all-day event (lead=0) is factually wrong.
* Timed events keep the existing "Starts in N min/hours/days" wording (default isAllDay=false).
*
* D-09: "Starts in 30 min" / "Starts in 1 hour" / "Starts in 1 day" etc.
* Branch order is important — check < 120 before the hours calculation to
* prevent Math.round(90/60)=2 erroneously giving "2 hours" for a 90-min lead.
*/
export function humanizeLeadMinutes(leadMinutes: number): string {
export function humanizeLeadMinutes(leadMinutes: number, isAllDay = false): string {
if (isAllDay) {
// Day-granularity wording for all-day event reminders
if (leadMinutes === 0) return 'Today';
if (leadMinutes <= 1440) return 'Tomorrow';
if (leadMinutes === 10080) return 'In 1 week';
return `In ${Math.round(leadMinutes / 1440)} days`;
}
if (leadMinutes < 60) return `Starts in ${leadMinutes} min`;
if (leadMinutes < 120) return 'Starts in 1 hour';
if (leadMinutes < 1440) return `Starts in ${Math.round(leadMinutes / 60)} hours`;
@@ -186,6 +198,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
dtstartMs: number; // key component — used for compound dedup key
pruneMs: number; // when to prune: dtstartUtc for timed; end-of-event-date for all-day
reminderLeadMinutes: number;
isAllDay: boolean; // CR-02 (Plan 05): needed for allDay-aware humanizeLeadMinutes
dateStr: string;
subs: SubRow[];
}
@@ -214,6 +227,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
dtstartMs,
pruneMs: dtstartMs, // timed: prune when event has started (dtstartUtc <= now)
reminderLeadMinutes: lead,
isAllDay: false, // CR-02: timed events use the existing "Starts in N..." wording
dateStr: yyyyMmDd(dtstartUtc),
subs: [],
});
@@ -262,6 +276,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
dtstartMs,
pruneMs,
reminderLeadMinutes: lead,
isAllDay: true, // CR-02: all-day events use day-granularity wording
dateStr: dtstartDate,
subs: [],
});
@@ -289,7 +304,8 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
// Null-safe title fallback (D-02 / NOTIF-01): use uid if title is NULL.
title: event.title ?? event.uid,
// D-09: humanized body driven by the configured lead (DB ground truth), not live delta.
body: humanizeLeadMinutes(event.reminderLeadMinutes),
// CR-02 (Plan 05): pass isAllDay so all-day events get day-granularity wording.
body: humanizeLeadMinutes(event.reminderLeadMinutes, event.isAllDay),
tag: `reminder-${event.uid}`,
navigate: `/calendar?date=${event.dateStr}&event=${event.uid}`,
};