mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show invitation times in your time zone #1252
This commit is contained in:
@@ -33,12 +33,15 @@ import {
|
||||
getInvitationActorSummary,
|
||||
getInvitationTrustAssessment,
|
||||
formatEventSummary,
|
||||
formatInvitationSchedule,
|
||||
findParticipantByEmail,
|
||||
extractMethodFromRawIcs,
|
||||
type EventSummary,
|
||||
type InvitationMethod,
|
||||
type InvitationTrustAssessment,
|
||||
} from '@/lib/calendar-invitation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getEventStartDate } from '@/lib/calendar-utils';
|
||||
import { sanitizeColor } from '@/components/calendar/event-card';
|
||||
import { RecipientPopover } from './recipient-popover';
|
||||
|
||||
@@ -260,7 +263,7 @@ function buildInvitationChangeItems(
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
currentEvent: Partial<CalendarEvent> | null,
|
||||
proposedEvent: Partial<CalendarEvent> | null,
|
||||
formatDateTime: (dateStr: string | null) => string,
|
||||
formatSchedule: (summary: EventSummary) => string,
|
||||
): InvitationChangeItem[] {
|
||||
if (!currentEvent || !proposedEvent) {
|
||||
return [];
|
||||
@@ -279,10 +282,10 @@ function buildInvitationChangeItems(
|
||||
}
|
||||
|
||||
const currentSchedule = currentSummary.start
|
||||
? `${formatDateTime(currentSummary.start)}${currentSummary.end ? ` - ${formatDateTime(currentSummary.end)}` : ''}`
|
||||
? formatSchedule(currentSummary)
|
||||
: t('change_empty');
|
||||
const proposedSchedule = proposedSummary.start
|
||||
? `${formatDateTime(proposedSummary.start)}${proposedSummary.end ? ` - ${formatDateTime(proposedSummary.end)}` : ''}`
|
||||
? formatSchedule(proposedSummary)
|
||||
: t('change_empty');
|
||||
if (currentSchedule !== proposedSchedule && proposedSummary.start) {
|
||||
changes.push({
|
||||
@@ -667,13 +670,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
};
|
||||
|
||||
const handleViewInCalendar = () => {
|
||||
const targetDate = existingEvent?.utcStart
|
||||
|| existingEvent?.start
|
||||
|| parsedEvent?.utcStart
|
||||
|| parsedEvent?.start;
|
||||
|
||||
if (targetDate) {
|
||||
const parsedDate = new Date(targetDate);
|
||||
const target = existingEvent?.start ? existingEvent : parsedEvent;
|
||||
if (target?.start) {
|
||||
const parsedDate = getEventStartDate({
|
||||
start: target.start,
|
||||
utcStart: target.utcStart ?? null,
|
||||
showWithoutTime: target.showWithoutTime ?? false,
|
||||
timeZone: target.timeZone,
|
||||
});
|
||||
if (!Number.isNaN(parsedDate.getTime())) {
|
||||
setSelectedDate(parsedDate);
|
||||
}
|
||||
@@ -708,31 +712,14 @@ 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',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: timeFormat === '12h',
|
||||
});
|
||||
};
|
||||
const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule(
|
||||
eventSummary,
|
||||
(date, options) => format.dateTime(date, options),
|
||||
timeFormat === '12h',
|
||||
);
|
||||
|
||||
const proposedChanges = method === 'counter' && existingEvent && parsedEvent
|
||||
? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatDateTime)
|
||||
? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatSchedule)
|
||||
: [];
|
||||
const canApplyProposal = Boolean(
|
||||
client
|
||||
@@ -887,8 +874,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{formatDateTime(summary.start)}
|
||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||
{formatSchedule(summary)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
getInvitationActorSummary,
|
||||
getInvitationMethod,
|
||||
getInvitationTrustAssessment,
|
||||
formatEventSummary,
|
||||
formatInvitationSchedule,
|
||||
findParticipantByEmail,
|
||||
} from '../calendar-invitation';
|
||||
import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
@@ -357,7 +358,7 @@ describe('formatEventSummary', () => {
|
||||
showWithoutTime: true,
|
||||
});
|
||||
expect(summary.start).toBe('2026-03-16T00:00:00');
|
||||
expect(summary.end).toBe('2026-03-17T00:00:00Z');
|
||||
expect(summary.end).toBe('2026-03-17T00:00:00');
|
||||
expect(summary.isAllDay).toBe(true);
|
||||
});
|
||||
|
||||
@@ -532,3 +533,99 @@ describe('findParticipantByEmail', () => {
|
||||
expect(findParticipantByEmail(event, '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invitation times across time zones (#1252)', () => {
|
||||
// A process zone that differs from both the organizer's and the viewer's,
|
||||
// so reading a LocalDateTime as browser-local would show up as a shift.
|
||||
let originalTZ: string | undefined;
|
||||
beforeAll(() => {
|
||||
originalTZ = process.env.TZ;
|
||||
process.env.TZ = 'America/New_York';
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalTZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = originalTZ;
|
||||
});
|
||||
|
||||
// What next-intl's format.dateTime does with the provider's display zone.
|
||||
const formatIn = (timeZone: string) => (date: Date, options: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat('en-US', { timeZone, ...options }).format(date);
|
||||
|
||||
// CalendarEvent/parse output for the Exchange invite in the issue.
|
||||
const londonInvite: Partial<CalendarEvent> = {
|
||||
title: 'Sync',
|
||||
start: '2026-10-20T15:00:00',
|
||||
timeZone: 'Europe/London',
|
||||
duration: 'PT25M',
|
||||
};
|
||||
|
||||
it('resolves start + timeZone to a real instant when there is no utcStart', () => {
|
||||
const summary = formatEventSummary(londonInvite);
|
||||
expect(summary.start).toBe('2026-10-20T14:00:00.000Z');
|
||||
expect(summary.end).toBe('2026-10-20T14:25:00.000Z');
|
||||
expect(summary.isFloating).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the invite in the viewer zone with a zone label', () => {
|
||||
const summary = formatEventSummary(londonInvite);
|
||||
expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false))
|
||||
.toBe('Tue, Oct 20, 16:00 – Tue, Oct 20, 16:25 GMT+2');
|
||||
expect(formatInvitationSchedule(summary, formatIn('America/New_York'), true))
|
||||
.toBe('Tue, Oct 20, 10:00 AM – Tue, Oct 20, 10:25 AM EDT');
|
||||
});
|
||||
|
||||
it('treats a UTC start as UTC', () => {
|
||||
const summary = formatEventSummary({ start: '2026-10-20T14:00:00', timeZone: 'Etc/UTC', duration: 'PT1H' });
|
||||
expect(summary.start).toBe('2026-10-20T14:00:00.000Z');
|
||||
expect(summary.end).toBe('2026-10-20T15:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps nominal days in the event zone across a DST change', () => {
|
||||
// Oct 25 2026 is the end of BST: 15:00 the next day is 15:00 GMT.
|
||||
const summary = formatEventSummary({ start: '2026-10-24T15:00:00', timeZone: 'Europe/London', duration: 'P1DT1H' });
|
||||
expect(summary.start).toBe('2026-10-24T14:00:00.000Z');
|
||||
expect(summary.end).toBe('2026-10-25T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('shows floating times as the same wall clock everywhere, without a zone label', () => {
|
||||
const summary = formatEventSummary({ start: '2026-10-20T15:00:00', timeZone: null, duration: 'PT30M' });
|
||||
expect(summary.isFloating).toBe(true);
|
||||
expect(formatInvitationSchedule(summary, formatIn('Asia/Tokyo'), false))
|
||||
.toBe('Tue, Oct 20, 15:00 – Tue, Oct 20, 15:30');
|
||||
});
|
||||
|
||||
it('does not shift all-day dates in zones west or east of UTC', () => {
|
||||
const oneDay = formatEventSummary({ start: '2026-03-16T00:00:00', duration: 'P1D', showWithoutTime: true, timeZone: 'Europe/London' });
|
||||
expect(formatInvitationSchedule(oneDay, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16');
|
||||
expect(formatInvitationSchedule(oneDay, formatIn('Pacific/Auckland'), false)).toBe('Mon, Mar 16');
|
||||
|
||||
const threeDays = formatEventSummary({
|
||||
start: '2026-03-16T00:00:00',
|
||||
showWithoutTime: true,
|
||||
utcStart: '2026-03-16T00:00:00Z',
|
||||
utcEnd: '2026-03-19T00:00:00Z',
|
||||
});
|
||||
expect(formatInvitationSchedule(threeDays, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16 – Wed, Mar 18');
|
||||
});
|
||||
|
||||
it('shows only the start for a zero-duration event', () => {
|
||||
const summary = formatEventSummary({
|
||||
...londonInvite,
|
||||
duration: 'PT0S',
|
||||
utcStart: '2026-10-20T14:00:00Z',
|
||||
utcEnd: '2026-10-20T14:00:00Z',
|
||||
});
|
||||
expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false))
|
||||
.toBe('Tue, Oct 20, 16:00 GMT+2');
|
||||
});
|
||||
|
||||
it('still prefers the server utcStart/utcEnd when present', () => {
|
||||
const summary = formatEventSummary({
|
||||
...londonInvite,
|
||||
utcStart: '2026-10-20T14:00:00Z',
|
||||
utcEnd: '2026-10-20T14:25:00Z',
|
||||
});
|
||||
expect(formatInvitationSchedule(summary, formatIn('UTC'), false))
|
||||
.toBe('Tue, Oct 20, 14:00 – Tue, Oct 20, 14:25 UTC');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,33 @@ describe('calendar-utils all-day handling', () => {
|
||||
expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z');
|
||||
});
|
||||
|
||||
it('reads start in the event timezone when utcStart is missing (CalendarEvent/parse, #1252)', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-10-20T15:00:00',
|
||||
duration: 'PT25M',
|
||||
timeZone: 'Europe/London',
|
||||
showWithoutTime: false,
|
||||
utcStart: undefined,
|
||||
utcEnd: undefined,
|
||||
});
|
||||
|
||||
expect(getEventStartDate(event).toISOString()).toBe('2026-10-20T14:00:00.000Z');
|
||||
expect(getEventEndDate(event).toISOString()).toBe('2026-10-20T14:25:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps floating start times as wall clock when utcStart is missing', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-10-20T15:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: null,
|
||||
showWithoutTime: false,
|
||||
utcStart: undefined,
|
||||
utcEnd: undefined,
|
||||
});
|
||||
|
||||
expectLocalDateParts(getEventStartDate(event), 2026, 10, 20, 15);
|
||||
});
|
||||
|
||||
it('clips timed multi-day events to the visible day bounds', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-03-14T22:00:00',
|
||||
|
||||
+179
-41
@@ -1,6 +1,6 @@
|
||||
import { parseISO } from 'date-fns';
|
||||
import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPart } from '@/lib/jmap/types';
|
||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||
import { fromWallClock, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone';
|
||||
|
||||
export type InvitationMethod =
|
||||
| 'publish'
|
||||
@@ -450,6 +450,8 @@ export interface EventSummary {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
isAllDay: boolean;
|
||||
/** Timed event without a usable time zone: start/end are wall-clock times to show as they are. */
|
||||
isFloating: boolean;
|
||||
location: string | null;
|
||||
organizer: string | null;
|
||||
organizerEmail: string | null;
|
||||
@@ -498,25 +500,14 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
}
|
||||
|
||||
const isAllDay = normalizedEvent.showWithoutTime ?? false;
|
||||
|
||||
let end: string | null = null;
|
||||
if (normalizedEvent.utcEnd) {
|
||||
end = normalizedEvent.utcEnd;
|
||||
} else if (normalizedEvent.start && normalizedEvent.duration) {
|
||||
end = addDurationToDate(normalizedEvent.start, normalizedEvent.duration, normalizedEvent.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
|
||||
? (normalizedEvent.start || null)
|
||||
: (normalizedEvent.utcStart || normalizedEvent.start || null);
|
||||
const { start, end, isFloating } = resolveSummaryTimes(normalizedEvent, isAllDay);
|
||||
|
||||
return {
|
||||
title: normalizedEvent.title || '',
|
||||
start,
|
||||
end,
|
||||
isAllDay,
|
||||
isFloating,
|
||||
location,
|
||||
organizer,
|
||||
organizerEmail,
|
||||
@@ -524,41 +515,188 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
|
||||
};
|
||||
}
|
||||
|
||||
function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null {
|
||||
const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
|
||||
|
||||
/** Wall-clock arithmetic on plain fields, independent of any zone. */
|
||||
function shiftWallClock(w: WallClock, days: number, seconds: number): WallClock {
|
||||
const d = new Date(0);
|
||||
d.setUTCFullYear(w.year, w.month - 1, w.day + days);
|
||||
d.setUTCHours(w.hour, w.minute, w.second + seconds, 0);
|
||||
return {
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth() + 1,
|
||||
day: d.getUTCDate(),
|
||||
hour: d.getUTCHours(),
|
||||
minute: d.getUTCMinutes(),
|
||||
second: d.getUTCSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatLocalDateTime(w: WallClock): string {
|
||||
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
|
||||
return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`;
|
||||
}
|
||||
|
||||
/** Days (nominal) and seconds (exact) of an ISO 8601 / JSCalendar duration. */
|
||||
function parseDurationParts(duration: string): { days: number; seconds: number } | null {
|
||||
const match = duration.match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
days: Number(match[1] || 0) * 7 + Number(match[2] || 0),
|
||||
seconds: Number(match[3] || 0) * 3600 + Number(match[4] || 0) * 60 + Number(match[5] || 0),
|
||||
};
|
||||
}
|
||||
|
||||
const weeks = parseInt(match[1] || '0');
|
||||
const days = parseInt(match[2] || '0') + weeks * 7;
|
||||
const hours = parseInt(match[3] || '0');
|
||||
const minutes = parseInt(match[4] || '0');
|
||||
const seconds = parseInt(match[5] || '0');
|
||||
function isValidInstant(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && HAS_UTC_OFFSET.test(value) && !Number.isNaN(new Date(value).getTime());
|
||||
}
|
||||
|
||||
const date = parseISO(start);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
/**
|
||||
* Start and end of an event for display. Timed events come back as UTC
|
||||
* instants (ISO strings ending in `Z`) so they can be shown in the user's
|
||||
* zone; all-day and floating events come back as LocalDateTime strings that
|
||||
* must be shown as the wall clock they are (#1252).
|
||||
*
|
||||
* CalendarEvent/parse returns `start` as a LocalDateTime in `timeZone` and
|
||||
* no `utcStart`, so reading `start` as an instant showed the organizer's
|
||||
* wall clock as if it were the user's.
|
||||
*/
|
||||
function resolveSummaryTimes(
|
||||
event: Partial<CalendarEvent>,
|
||||
isAllDay: boolean,
|
||||
): { start: string | null; end: string | null; isFloating: boolean } {
|
||||
const duration = event.duration ? parseDurationParts(event.duration) : null;
|
||||
const localStart = event.start ? parseLocalDateTime(event.start) : null;
|
||||
|
||||
const isUTC = start.endsWith('Z') || start.includes('+');
|
||||
|
||||
if (isUTC) {
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
date.setUTCHours(date.getUTCHours() + hours);
|
||||
date.setUTCMinutes(date.getUTCMinutes() + minutes);
|
||||
date.setUTCSeconds(date.getUTCSeconds() + seconds);
|
||||
return date.toISOString();
|
||||
if (isAllDay) {
|
||||
// The date is the same everywhere: never convert it through a zone.
|
||||
if (!localStart) return { start: event.start || null, end: null, isFloating: false };
|
||||
let end: string | null = null;
|
||||
if (duration) {
|
||||
end = formatLocalDateTime(shiftWallClock(localStart, duration.days, duration.seconds));
|
||||
} else if (event.utcEnd) {
|
||||
const endDate = parseLocalDateTime(event.utcEnd.slice(0, 10));
|
||||
if (endDate) end = formatLocalDateTime(endDate);
|
||||
}
|
||||
return { start: formatLocalDateTime(localStart), end, isFloating: false };
|
||||
}
|
||||
|
||||
date.setDate(date.getDate() + days);
|
||||
date.setHours(date.getHours() + hours);
|
||||
date.setMinutes(date.getMinutes() + minutes);
|
||||
date.setSeconds(date.getSeconds() + seconds);
|
||||
const addExact = (instant: Date, days: number, seconds: number) =>
|
||||
new Date(instant.getTime() + (days * 86400 + seconds) * 1000).toISOString();
|
||||
|
||||
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}`;
|
||||
if (isValidInstant(event.utcStart)) {
|
||||
const end = isValidInstant(event.utcEnd)
|
||||
? event.utcEnd
|
||||
: duration ? addExact(new Date(event.utcStart), duration.days, duration.seconds) : null;
|
||||
return { start: event.utcStart, end, isFloating: false };
|
||||
}
|
||||
|
||||
if (isValidInstant(event.start)) {
|
||||
const end = isValidInstant(event.utcEnd)
|
||||
? event.utcEnd
|
||||
: duration ? addExact(new Date(event.start), duration.days, duration.seconds) : null;
|
||||
return { start: new Date(event.start).toISOString(), end, isFloating: false };
|
||||
}
|
||||
|
||||
if (!localStart) return { start: event.start || null, end: null, isFloating: false };
|
||||
|
||||
if (event.timeZone && isValidTimeZone(event.timeZone)) {
|
||||
const timeZone = event.timeZone;
|
||||
const startInstant = fromWallClock(localStart, timeZone);
|
||||
let end: string | null = null;
|
||||
if (isValidInstant(event.utcEnd)) {
|
||||
end = event.utcEnd;
|
||||
} else if (duration) {
|
||||
// Days are nominal (same wall clock N days later in the event's zone),
|
||||
// hours/minutes/seconds are exact.
|
||||
const dayShifted = fromWallClock(shiftWallClock(localStart, duration.days, 0), timeZone);
|
||||
end = addExact(dayShifted, 0, duration.seconds);
|
||||
}
|
||||
return { start: startInstant.toISOString(), end, isFloating: false };
|
||||
}
|
||||
|
||||
// Floating time (no zone, or one we can't resolve): same wall clock everywhere.
|
||||
const end = duration
|
||||
? formatLocalDateTime(shiftWallClock(localStart, duration.days, duration.seconds))
|
||||
: null;
|
||||
return { start: formatLocalDateTime(localStart), end, isFloating: true };
|
||||
}
|
||||
|
||||
export interface InvitationDisplayTime {
|
||||
date: Date;
|
||||
/**
|
||||
* True when `date` carries a wall clock in its UTC fields (all-day or
|
||||
* floating). Format it with `timeZone: 'UTC'` so it is not converted.
|
||||
*/
|
||||
wallClock: boolean;
|
||||
}
|
||||
|
||||
function toInvitationDisplayTime(value: string | null): InvitationDisplayTime | null {
|
||||
if (!value) return null;
|
||||
if (HAS_UTC_OFFSET.test(value)) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : { date, wallClock: false };
|
||||
}
|
||||
const w = parseLocalDateTime(value);
|
||||
if (!w) return null;
|
||||
const date = new Date(0);
|
||||
date.setUTCFullYear(w.year, w.month - 1, w.day);
|
||||
date.setUTCHours(w.hour, w.minute, w.second, 0);
|
||||
return { date, wallClock: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the invitation banner shows for an event summary. All-day events get
|
||||
* their last day (the stored end is exclusive), or no end for a single day.
|
||||
*/
|
||||
export function getInvitationDisplayRange(
|
||||
summary: Pick<EventSummary, 'start' | 'end' | 'isAllDay'>,
|
||||
): { start: InvitationDisplayTime; end: InvitationDisplayTime | null } | null {
|
||||
const start = toInvitationDisplayTime(summary.start);
|
||||
if (!start) return null;
|
||||
let end = toInvitationDisplayTime(summary.end);
|
||||
if (end && summary.isAllDay) {
|
||||
const lastDay = new Date(end.date.getTime());
|
||||
lastDay.setUTCDate(lastDay.getUTCDate() - 1);
|
||||
end = lastDay.getTime() > start.date.getTime() ? { date: lastDay, wallClock: true } : null;
|
||||
} else if (end && end.date.getTime() <= start.date.getTime()) {
|
||||
// Zero-duration event (utcEnd == utcStart): no separate end to show.
|
||||
end = null;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Intl options limited to what next-intl's format.dateTime accepts. */
|
||||
export type InvitationDateFormatOptions = Pick<
|
||||
Intl.DateTimeFormatOptions,
|
||||
'weekday' | 'month' | 'day' | 'hour' | 'minute' | 'hour12' | 'timeZone'
|
||||
> & {
|
||||
timeZoneName?: 'short' | 'long';
|
||||
};
|
||||
|
||||
/**
|
||||
* The banner's "when" line. `formatDate` is next-intl's `format.dateTime`
|
||||
* (which formats in the user's display zone) or an Intl equivalent. Timed
|
||||
* events with a real zone get a zone label so it is clear which clock the
|
||||
* time is on.
|
||||
*/
|
||||
export function formatInvitationSchedule(
|
||||
summary: Pick<EventSummary, 'start' | 'end' | 'isAllDay'>,
|
||||
formatDate: (date: Date, options: InvitationDateFormatOptions) => string,
|
||||
hour12: boolean,
|
||||
): string {
|
||||
const range = getInvitationDisplayRange(summary);
|
||||
if (!range) return summary.start ?? '';
|
||||
const base: InvitationDateFormatOptions = summary.isAllDay
|
||||
? { weekday: 'short', month: 'short', day: 'numeric' }
|
||||
: { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12 };
|
||||
const formatPoint = (point: InvitationDisplayTime, last: boolean) => formatDate(point.date, {
|
||||
...base,
|
||||
...(point.wallClock ? { timeZone: 'UTC' } : {}),
|
||||
...(last && !point.wallClock && !summary.isAllDay ? { timeZoneName: 'short' } : {}),
|
||||
});
|
||||
const start = formatPoint(range.start, !range.end);
|
||||
return range.end ? `${start} – ${formatPoint(range.end, true)}` : start;
|
||||
}
|
||||
|
||||
export function findParticipantByEmail(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
||||
import { parseDuration } from "@/components/calendar/event-card";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { toDisplayDate } from "@/lib/timezone";
|
||||
import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone";
|
||||
|
||||
export interface CalendarWeekSegment {
|
||||
event: CalendarEvent;
|
||||
@@ -30,7 +30,7 @@ export interface TimedEventLayout {
|
||||
* with `fromDisplayDate` before turning grid positions into instants.
|
||||
*/
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'> & Partial<Pick<CalendarEvent, 'timeZone'>>,
|
||||
): Date {
|
||||
// Prefer utcStart for timed events but fall back to start if utcStart is
|
||||
// missing or unparseable - a malformed utcStart used to surface as an
|
||||
@@ -39,6 +39,12 @@ export function getEventStartDate(
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return toDisplayDate(utc);
|
||||
}
|
||||
// CalendarEvent/parse (.ics import, invitations) returns no utcStart: read
|
||||
// `start` as the wall clock in the event's own zone, not the user's (#1252).
|
||||
if (!event.showWithoutTime && event.timeZone && isValidTimeZone(event.timeZone)) {
|
||||
const wall = event.start ? parseLocalDateTime(event.start) : null;
|
||||
if (wall) return toDisplayDate(fromWallClock(wall, event.timeZone));
|
||||
}
|
||||
return parseISO(event.start);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,24 @@ export interface WallClock {
|
||||
second: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock fields of a JMAP LocalDateTime ("YYYY-MM-DDTHH:mm:ss", or a bare
|
||||
* date). Returns null for anything else, including strings with `Z` or an
|
||||
* offset, which are instants rather than wall clocks.
|
||||
*/
|
||||
export function parseLocalDateTime(value: string): WallClock | null {
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3]),
|
||||
hour: Number(match[4] || 0),
|
||||
minute: Number(match[5] || 0),
|
||||
second: Number(match[6] || 0),
|
||||
};
|
||||
}
|
||||
|
||||
// Constructing Intl.DateTimeFormat is expensive (ICU data lookup); cache one
|
||||
// formatter per zone - the calendar calls into this for every event.
|
||||
const wallClockFormatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
Reference in New Issue
Block a user