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; }