fix: proper support for all-day events

This commit is contained in:
Linus Rath
2026-03-17 20:51:47 +01:00
parent 6e78f2a09e
commit a2cb2b2c86
4 changed files with 87 additions and 14 deletions
@@ -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',
+33 -4
View File
@@ -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');
});
});
+24 -1
View File
@@ -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<CalendarEvent>): 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<CalendarEvent>): 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();
}
+20 -9
View File
@@ -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) {