fix: proper support for all-day events
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user