From a2cb2b2c8671642e1fdf0aec7003ac29abdeca68 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Tue, 17 Mar 2026 20:51:47 +0100 Subject: [PATCH] fix: proper support for all-day events --- .../email/calendar-invitation-banner.tsx | 10 +++++ lib/__tests__/calendar-invitation.test.ts | 37 +++++++++++++++++-- lib/calendar-invitation.ts | 25 ++++++++++++- lib/jmap/client.ts | 29 ++++++++++----- 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 6f815da9..84f7f70b 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -544,6 +544,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp dtStart: parsedEvent.start || undefined, dtEnd: summary?.end || undefined, timeZone: parsedEvent.timeZone || undefined, + isAllDay: parsedEvent.showWithoutTime || false, sequence: parsedEvent.sequence, status: imipStatus, }); @@ -659,10 +660,19 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }; + const isAllDayEvent = parsedEvent?.showWithoutTime ?? false; + const formatDateTime = (dateStr: string | null) => { if (!dateStr) return ''; const date = new Date(dateStr); if (isNaN(date.getTime())) return dateStr; + if (isAllDayEvent) { + return format.dateTime(date, { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + } return format.dateTime(date, { weekday: 'short', month: 'short', diff --git a/lib/__tests__/calendar-invitation.test.ts b/lib/__tests__/calendar-invitation.test.ts index 942ec019..43a63f0b 100644 --- a/lib/__tests__/calendar-invitation.test.ts +++ b/lib/__tests__/calendar-invitation.test.ts @@ -335,10 +335,7 @@ describe('formatEventSummary', () => { duration: 'PT1H30M', }); expect(summary.start).toBe('2026-02-17T10:00:00'); - expect(summary.end).toBeTruthy(); - const endDate = new Date(summary.end!); - expect(endDate.getHours()).toBe(new Date('2026-02-17T10:00:00').getHours() + 1); - expect(endDate.getMinutes()).toBe(new Date('2026-02-17T10:00:00').getMinutes() + 30); + expect(summary.end).toBe('2026-02-17T11:30:00'); }); it('uses utcStart and utcEnd when available', () => { @@ -349,6 +346,38 @@ describe('formatEventSummary', () => { }); expect(summary.start).toBe('2026-02-17T15:00:00Z'); expect(summary.end).toBe('2026-02-17T16:00:00Z'); + expect(summary.isAllDay).toBe(false); + }); + + it('prefers local start over utcStart for all-day events', () => { + const summary = formatEventSummary({ + utcStart: '2026-03-16T00:00:00Z', + utcEnd: '2026-03-17T00:00:00Z', + start: '2026-03-16T00:00:00', + showWithoutTime: true, + }); + expect(summary.start).toBe('2026-03-16T00:00:00'); + expect(summary.end).toBe('2026-03-17T00:00:00Z'); + expect(summary.isAllDay).toBe(true); + }); + + it('computes all-day end in local format when utcEnd is missing', () => { + const summary = formatEventSummary({ + start: '2026-03-16T00:00:00', + duration: 'P1D', + showWithoutTime: true, + }); + expect(summary.start).toBe('2026-03-16T00:00:00'); + expect(summary.end).toBe('2026-03-17T00:00:00'); + expect(summary.isAllDay).toBe(true); + }); + + it('returns local format end for timed events with local start', () => { + const summary = formatEventSummary({ + start: '2026-02-17T10:00:00', + duration: 'PT1H30M', + }); + expect(summary.end).toBe('2026-02-17T11:30:00'); }); }); diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index b4aa8684..ad454a14 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -449,6 +449,7 @@ export interface EventSummary { title: string; start: string | null; end: string | null; + isAllDay: boolean; location: string | null; organizer: string | null; organizerEmail: string | null; @@ -495,6 +496,8 @@ export function formatEventSummary(event: Partial): EventSummary if (!organizer) organizer = organizerEmail; } + const isAllDay = event.showWithoutTime ?? false; + let end: string | null = null; if (event.utcEnd) { end = event.utcEnd; @@ -502,10 +505,17 @@ export function formatEventSummary(event: Partial): EventSummary end = addDurationToDate(event.start, event.duration, event.timeZone); } + // For all-day events, prefer the local start (no timezone) to avoid + // UTC conversion shifting the displayed date in non-UTC timezones. + const start = isAllDay + ? (event.start || null) + : (event.utcStart || event.start || null); + return { title: event.title || '', - start: event.utcStart || event.start || null, + start, end, + isAllDay, location, organizer, organizerEmail, @@ -530,6 +540,19 @@ function addDurationToDate(start: string, duration: string, _timeZone?: string | date.setMinutes(date.getMinutes() + minutes); date.setSeconds(date.getSeconds() + seconds); + // If the input is a local datetime (no UTC 'Z' suffix), return a local + // format string so that all-day date arithmetic isn't shifted by the + // browser's UTC offset (toISOString converts to UTC). + if (!start.endsWith('Z') && !start.includes('+')) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + return `${y}-${m}-${d}T${h}:${min}:${s}`; + } + return date.toISOString(); } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 168684db..16287a23 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1490,6 +1490,7 @@ export class JMAPClient { dtStart?: string; dtEnd?: string; timeZone?: string; + isAllDay?: boolean; sequence?: number; status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED'; identityId?: string; @@ -1542,20 +1543,30 @@ export class JMAPClient { `DTSTAMP:${now}`, ]; if (opts.dtStart) { - const formatted = formatIcalDate(opts.dtStart, opts.timeZone); - // If TZID is included, it's a parameter on the property - if (formatted.startsWith('TZID=')) { - lines.push(`DTSTART;${formatted}`); + if (opts.isAllDay) { + // RFC 5545 ยง3.3.4: all-day events use VALUE=DATE (date-only, no time) + const dateOnly = opts.dtStart.replace(/[-]/g, '').substring(0, 8); + lines.push(`DTSTART;VALUE=DATE:${dateOnly}`); } else { - lines.push(`DTSTART:${formatted}`); + const formatted = formatIcalDate(opts.dtStart, opts.timeZone); + if (formatted.startsWith('TZID=')) { + lines.push(`DTSTART;${formatted}`); + } else { + lines.push(`DTSTART:${formatted}`); + } } } if (opts.dtEnd) { - const formatted = formatIcalDate(opts.dtEnd, opts.timeZone); - if (formatted.startsWith('TZID=')) { - lines.push(`DTEND;${formatted}`); + if (opts.isAllDay) { + const dateOnly = opts.dtEnd.replace(/[-]/g, '').substring(0, 8); + lines.push(`DTEND;VALUE=DATE:${dateOnly}`); } else { - lines.push(`DTEND:${formatted}`); + const formatted = formatIcalDate(opts.dtEnd, opts.timeZone); + if (formatted.startsWith('TZID=')) { + lines.push(`DTEND;${formatted}`); + } else { + lines.push(`DTEND:${formatted}`); + } } } if (opts.summary) {