From 35660690d2e738d75808183def5a46fe2c346303 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:57:56 +0200 Subject: [PATCH] fix: fire task alerts from the start when they are relative to it #1328 --- lib/__tests__/calendar-alerts.test.ts | 25 +++++++++++++++++++++++++ lib/calendar-alerts.ts | 7 +++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts index 484ece3a7..c54f5679b 100644 --- a/lib/__tests__/calendar-alerts.test.ts +++ b/lib/__tests__/calendar-alerts.test.ts @@ -11,11 +11,13 @@ import { alertsToRows, rowsToAlerts, newAlertRow, + computeTaskFireTime, } from '../calendar-alerts'; import type { CalendarEvent, CalendarEventAlert, Calendar, + CalendarTask, } from '@/lib/jmap/types'; function makeEvent(overrides: Partial = {}): CalendarEvent { @@ -521,3 +523,26 @@ describe('getPendingAlerts', () => { expect(result[0].calendarName).toBeNull(); }); }); + +describe('computeTaskFireTime (#1328)', () => { + const task = (start: string | null, due: string | null) => ({ start, due } as CalendarTask); + const offset = (relativeTo?: 'start' | 'end') => ({ '@type': 'OffsetTrigger' as const, offset: '-PT5M', ...(relativeTo ? { relativeTo } : {}) }) as Parameters[1]; + const at = (s: string) => new Date(s).getTime(); + + it('counts a start-relative alert from the start', () => { + expect(computeTaskFireTime(task('2026-10-23T20:00:00Z', '2026-10-23T20:15:00Z'), offset('start'))).toBe(at('2026-10-23T19:55:00Z')); + }); + + it('treats a missing relativeTo as start, as Stalwart sends it', () => { + expect(computeTaskFireTime(task('2026-10-23T20:00:00Z', '2026-10-23T20:15:00Z'), offset())).toBe(at('2026-10-23T19:55:00Z')); + }); + + it('counts an end-relative alert from the due time', () => { + expect(computeTaskFireTime(task('2026-10-23T20:00:00Z', '2026-10-23T20:15:00Z'), offset('end'))).toBe(at('2026-10-23T20:10:00Z')); + }); + + it('falls back to the due time when the task has no start', () => { + expect(computeTaskFireTime(task(null, '2026-10-23T20:15:00Z'), offset('start'))).toBe(at('2026-10-23T20:10:00Z')); + expect(computeTaskFireTime(task(null, null), offset('start'))).toBeNull(); + }); +}); diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index ebcd2d36b..8d6d75cd9 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -275,8 +275,11 @@ export function computeTaskFireTime( const offsetMs = parseAlertOffset(trigger.offset); if (offsetMs === null) return null; - if (!task.due) return null; - const baseTime = new Date(task.due).getTime(); + // RFC 8984 ยง4.5.2: relativeTo defaults to "start", which for a Task is its + // start; "end" is its due. A task without a start counts from its due (#1328). + const base = trigger.relativeTo !== 'end' && task.start ? task.start : task.due; + if (!base) return null; + const baseTime = new Date(base).getTime(); if (Number.isNaN(baseTime)) return null; return baseTime + offsetMs; }