From 6f475c0c52751c3712a513be233dc8a1fea92250 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:14:58 +0100 Subject: [PATCH] fix: remind of a task relative to its start when the reminder says so #83 --- .../calendar-alert-scheduler.test.ts | 31 +++++++++++++++++-- src/lib/calendar-alert-scheduler.ts | 10 ++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/lib/__tests__/calendar-alert-scheduler.test.ts b/src/lib/__tests__/calendar-alert-scheduler.test.ts index 6147cf08..9a5c7864 100644 --- a/src/lib/__tests__/calendar-alert-scheduler.test.ts +++ b/src/lib/__tests__/calendar-alert-scheduler.test.ts @@ -49,8 +49,8 @@ describe('parseAlertOffset / computeFireTime', () => { ); }); - it('fires task alerts relative to due', () => { - const task = { ...event, due: '2026-03-02T09:00:00', alerts: undefined }; + it('fires the alerts of a task without a start relative to its due', () => { + const task = { ...event, start: undefined as unknown as string, utcStart: undefined, due: '2026-03-02T09:00:00', alerts: undefined }; expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe( new Date('2026-03-02T09:00:00').getTime() - HOUR, ); @@ -58,11 +58,32 @@ describe('parseAlertOffset / computeFireTime', () => { }); it('reads a zoned task due in its own time zone', () => { - const task = { ...event, due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin', alerts: undefined }; + const task = { ...event, start: undefined as unknown as string, utcStart: undefined, due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin', alerts: undefined }; expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe( new Date('2026-07-01T14:00:00Z').getTime(), ); }); + + // #83 (webmail #1328): start 20:00, due 20:15, five minutes before the start. + it('fires a task alert relative to its start from the start, and one relative to its end from the due', () => { + const task = { + ...event, start: '2026-07-01T20:00:00', utcStart: undefined, due: '2026-07-01T20:15:00', timeZone: 'Europe/Berlin', alerts: undefined, + }; + expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' })).toBe( + new Date('2026-07-01T17:55:00Z').getTime(), + ); + // relativeTo left out means the start. + expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M' })).toBe( + new Date('2026-07-01T17:55:00Z').getTime(), + ); + expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' })).toBe( + new Date('2026-07-01T18:10:00Z').getTime(), + ); + // The server's UTC start, when it gave one. + expect(computeTaskFireTime({ ...task, utcStart: '2026-07-01T18:00:00Z' }, { '@type': 'OffsetTrigger', offset: '-PT5M' })).toBe( + new Date('2026-07-01T17:55:00Z').getTime(), + ); + }); }); describe('getEffectiveAlerts', () => { @@ -91,6 +112,8 @@ describe('getUpcomingAlerts', () => { const task = { ...event, id: 't1', + start: undefined as unknown as string, + utcStart: undefined, due: '2026-03-01T12:00:00Z', alerts: { x: { trigger: { '@type': 'OffsetTrigger', offset: 'PT0S' }, action: 'display' } }, }; @@ -109,6 +132,8 @@ describe('getUpcomingAlerts', () => { const task = { ...untitled, id: 't1', + start: undefined as unknown as string, + utcStart: undefined, due: '2026-03-01T12:00:00Z', alerts: { x: { trigger: { '@type': 'OffsetTrigger' as const, offset: 'PT0S' }, action: 'display' as const } }, }; diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts index a224e7c4..c02345f4 100644 --- a/src/lib/calendar-alert-scheduler.ts +++ b/src/lib/calendar-alert-scheduler.ts @@ -83,6 +83,16 @@ export function computeTaskFireTime(task: CalendarEvent, trigger: Alert['trigger if (trigger.offset === undefined) return null; const offsetMs = parseAlertOffset(trigger.offset); if (offsetMs === null) return null; + // 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 + // (#83, webmail #1328). + if (trigger.relativeTo !== 'end' && task.start) { + const start = task.utcStart + ? new Date(task.utcStart) + // The start read like a due: in the task's zone, or a calendar date. + : getTaskDueDate({ due: task.start, timeZone: task.timeZone, showWithoutTime: task.showWithoutTime }); + if (start && !Number.isNaN(start.getTime())) return start.getTime() + offsetMs; + } const due = getTaskDueDate(task); if (!due) return null; return due.getTime() + offsetMs;