fix: proper support for all-day events
This commit is contained in:
@@ -544,6 +544,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
dtStart: parsedEvent.start || undefined,
|
dtStart: parsedEvent.start || undefined,
|
||||||
dtEnd: summary?.end || undefined,
|
dtEnd: summary?.end || undefined,
|
||||||
timeZone: parsedEvent.timeZone || undefined,
|
timeZone: parsedEvent.timeZone || undefined,
|
||||||
|
isAllDay: parsedEvent.showWithoutTime || false,
|
||||||
sequence: parsedEvent.sequence,
|
sequence: parsedEvent.sequence,
|
||||||
status: imipStatus,
|
status: imipStatus,
|
||||||
});
|
});
|
||||||
@@ -659,10 +660,19 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAllDayEvent = parsedEvent?.showWithoutTime ?? false;
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string | null) => {
|
const formatDateTime = (dateStr: string | null) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
if (isNaN(date.getTime())) return dateStr;
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
|
if (isAllDayEvent) {
|
||||||
|
return format.dateTime(date, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
return format.dateTime(date, {
|
return format.dateTime(date, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|||||||
@@ -335,10 +335,7 @@ describe('formatEventSummary', () => {
|
|||||||
duration: 'PT1H30M',
|
duration: 'PT1H30M',
|
||||||
});
|
});
|
||||||
expect(summary.start).toBe('2026-02-17T10:00:00');
|
expect(summary.start).toBe('2026-02-17T10:00:00');
|
||||||
expect(summary.end).toBeTruthy();
|
expect(summary.end).toBe('2026-02-17T11:30:00');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses utcStart and utcEnd when available', () => {
|
it('uses utcStart and utcEnd when available', () => {
|
||||||
@@ -349,6 +346,38 @@ describe('formatEventSummary', () => {
|
|||||||
});
|
});
|
||||||
expect(summary.start).toBe('2026-02-17T15:00:00Z');
|
expect(summary.start).toBe('2026-02-17T15:00:00Z');
|
||||||
expect(summary.end).toBe('2026-02-17T16: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;
|
title: string;
|
||||||
start: string | null;
|
start: string | null;
|
||||||
end: string | null;
|
end: string | null;
|
||||||
|
isAllDay: boolean;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
organizer: string | null;
|
organizer: string | null;
|
||||||
organizerEmail: string | null;
|
organizerEmail: string | null;
|
||||||
@@ -495,6 +496,8 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
|||||||
if (!organizer) organizer = organizerEmail;
|
if (!organizer) organizer = organizerEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAllDay = event.showWithoutTime ?? false;
|
||||||
|
|
||||||
let end: string | null = null;
|
let end: string | null = null;
|
||||||
if (event.utcEnd) {
|
if (event.utcEnd) {
|
||||||
end = event.utcEnd;
|
end = event.utcEnd;
|
||||||
@@ -502,10 +505,17 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
|||||||
end = addDurationToDate(event.start, event.duration, event.timeZone);
|
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 {
|
return {
|
||||||
title: event.title || '',
|
title: event.title || '',
|
||||||
start: event.utcStart || event.start || null,
|
start,
|
||||||
end,
|
end,
|
||||||
|
isAllDay,
|
||||||
location,
|
location,
|
||||||
organizer,
|
organizer,
|
||||||
organizerEmail,
|
organizerEmail,
|
||||||
@@ -530,6 +540,19 @@ function addDurationToDate(start: string, duration: string, _timeZone?: string |
|
|||||||
date.setMinutes(date.getMinutes() + minutes);
|
date.setMinutes(date.getMinutes() + minutes);
|
||||||
date.setSeconds(date.getSeconds() + seconds);
|
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();
|
return date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-9
@@ -1490,6 +1490,7 @@ export class JMAPClient {
|
|||||||
dtStart?: string;
|
dtStart?: string;
|
||||||
dtEnd?: string;
|
dtEnd?: string;
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
|
isAllDay?: boolean;
|
||||||
sequence?: number;
|
sequence?: number;
|
||||||
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
@@ -1542,20 +1543,30 @@ export class JMAPClient {
|
|||||||
`DTSTAMP:${now}`,
|
`DTSTAMP:${now}`,
|
||||||
];
|
];
|
||||||
if (opts.dtStart) {
|
if (opts.dtStart) {
|
||||||
const formatted = formatIcalDate(opts.dtStart, opts.timeZone);
|
if (opts.isAllDay) {
|
||||||
// If TZID is included, it's a parameter on the property
|
// RFC 5545 §3.3.4: all-day events use VALUE=DATE (date-only, no time)
|
||||||
if (formatted.startsWith('TZID=')) {
|
const dateOnly = opts.dtStart.replace(/[-]/g, '').substring(0, 8);
|
||||||
lines.push(`DTSTART;${formatted}`);
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
||||||
} else {
|
} 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) {
|
if (opts.dtEnd) {
|
||||||
const formatted = formatIcalDate(opts.dtEnd, opts.timeZone);
|
if (opts.isAllDay) {
|
||||||
if (formatted.startsWith('TZID=')) {
|
const dateOnly = opts.dtEnd.replace(/[-]/g, '').substring(0, 8);
|
||||||
lines.push(`DTEND;${formatted}`);
|
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
||||||
} else {
|
} 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) {
|
if (opts.summary) {
|
||||||
|
|||||||
Reference in New Issue
Block a user