fix: show invitation times in your time zone #1252

This commit is contained in:
Linus Rath
2026-10-17 19:17:49 +02:00
parent deb106736e
commit d47f26da20
6 changed files with 352 additions and 80 deletions
+21 -35
View File
@@ -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>
)}
+99 -2
View File
@@ -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');
});
});
+27
View File
@@ -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
View File
@@ -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(
+8 -2
View File
@@ -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);
}
+18
View File
@@ -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>();