fix: show an invitation's time in the user's time zone, with its name

This commit is contained in:
Linus Rath
2026-10-29 03:26:00 +01:00
parent 7a95d43f1a
commit 0fb7c8cd09
3 changed files with 140 additions and 10 deletions
@@ -5,7 +5,7 @@ import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
ShieldCheck, ShieldAlert, ChevronDown,
} from '../icons';
import { format, parseISO } from 'date-fns';
import { format, isSameDay } from 'date-fns';
import type { Calendar, Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -17,9 +17,11 @@ import {
calendarInvitationKey,
findCalendarAttachment,
findParticipantByEmail,
getInvitationDisplayRange,
getInvitationMethod,
getInvitationTrustAssessment,
getOrganizerName,
invitationZoneLabel,
buildReplyTo,
type InvitationMethod,
type InvitationTrustAssessment,
@@ -65,7 +67,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const dateLocale = getDateFnsLocale(useLocaleStore((s) => s.locale));
const locale = useLocaleStore((s) => s.locale);
const dateLocale = getDateFnsLocale(locale);
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const calendars = useCalendarStore((s) => s.calendars);
@@ -172,13 +175,25 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
}
if (!event) return null;
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
const startDate = startStr ? parseISO(startStr) : null;
const dateLabel = startDate && !isNaN(startDate.getTime())
? (event.showWithoutTime
? format(startDate, 'EEEE, MMM d, yyyy', { locale: dateLocale })
: format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale: dateLocale }))
: null;
// In the user's zone, with its name, for an event at a fixed moment; the
// organizer's wall clock otherwise read as a device time (webmail #1252).
const range = getInvitationDisplayRange(event);
let dateLabel: string | null = null;
if (range) {
const time = timePattern(timeFormat);
if (event.showWithoutTime) {
const day = 'EEEE, MMM d, yyyy';
dateLabel = format(range.start, day, { locale: dateLocale })
+ (range.end ? ` – ${format(range.end, day, { locale: dateLocale })}` : '');
} else {
dateLabel = format(range.start, `EEE, MMM d · ${time}`, { locale: dateLocale });
if (range.end) {
dateLabel += ` – ${format(range.end, isSameDay(range.start, range.end) ? time : `EEE, MMM d · ${time}`, { locale: dateLocale })}`;
}
const zone = range.instant ? invitationZoneLabel(range.instant, locale) : null;
if (zone) dateLabel += ` ${zone}`;
}
}
const organizer = getOrganizerName(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
+42 -1
View File
@@ -1,16 +1,57 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { format } from 'date-fns';
import type { Email } from '../../api/types';
import { useSettingsStore } from '../../stores/settings-store';
import {
calendarInvitationKey,
extractMethodFromContentType,
extractMethodFromRawIcs,
findCalendarAttachment,
getEmailAuthenticationResults,
getInvitationDisplayRange,
getInvitationMethod,
getInvitationTrustAssessment,
invitationZoneLabel,
parseAuthenticationResults,
} from '../calendar-invitation';
describe('getInvitationDisplayRange (webmail #1252)', () => {
afterEach(() => {
useSettingsStore.setState({ calendarTimeZone: 'auto' });
});
const shown = (d: Date | null | undefined) => (d ? format(d, 'yyyy-MM-dd HH:mm') : null);
it('shows an organizer\'s wall clock in the user\'s zone', () => {
useSettingsStore.setState({ calendarTimeZone: 'Europe/Warsaw' });
// As CalendarEvent/parse gives an Exchange invitation from London: no utcStart.
const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', duration: 'PT25M' });
expect(shown(range?.start)).toBe('2026-10-20 16:00');
expect(shown(range?.end)).toBe('2026-10-20 16:25');
expect(range?.instant?.toISOString()).toBe('2026-10-20T14:00:00.000Z');
expect(invitationZoneLabel(range!.instant!, 'en')).toBeTruthy();
});
it('takes the server\'s UTC start when it has one', () => {
useSettingsStore.setState({ calendarTimeZone: 'America/New_York' });
const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', utcStart: '2026-10-20T14:00:00Z', utcEnd: '2026-10-20T15:00:00Z' });
expect(shown(range?.start)).toBe('2026-10-20 10:00');
expect(shown(range?.end)).toBe('2026-10-20 11:00');
});
it('leaves floating times and all-day dates as written', () => {
useSettingsStore.setState({ calendarTimeZone: 'America/New_York' });
const floating = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', duration: 'PT1H' });
expect(shown(floating?.start)).toBe('2026-10-20 15:00');
expect(floating?.instant).toBeUndefined();
const oneDay = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P1D', showWithoutTime: true });
expect(shown(oneDay?.start)).toBe('2026-10-20 00:00');
expect(oneDay?.end).toBeNull();
// The stored end is exclusive: three days end on the third.
const threeDays = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P3D', showWithoutTime: true });
expect(shown(threeDays?.end)).toBe('2026-10-22 00:00');
});
});
const request = {
organizerCalendarAddress: 'mailto:alice@example.com',
participants: {
+74
View File
@@ -1,4 +1,6 @@
import type { CalendarEvent, Participant, Email, Attachment, BodyPart, EmailAddress } from '../api/types';
import { parseDuration, parseLocalDateTime } from './calendar-utils';
import { getEffectiveTimeZone, isValidTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone';
// ─── Address helpers ─────────────────────────────────────
@@ -451,3 +453,75 @@ export function getInvitationTrustAssessment(
}
return { level: 'trusted', reason: null, senderEmail, organizerEmail };
}
// ─── When ────────────────────────────────────────────────
/** What the invitation banner shows as an event's time. */
export interface InvitationDisplayRange {
/** Display dates (see toDisplayDate): their local fields are what to show. */
start: Date;
end: Date | null;
/**
* The real start instant, for an event at a fixed moment (a UTC time or a
* zoned wall clock) shown in the user's zone: worth a zone label. Unset
* for all-day and floating events, which read the same everywhere.
*/
instant?: Date;
}
const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
/**
* The banner's start and end of an invitation. CalendarEvent/parse gives
* `start` as a wall clock in `timeZone` and no `utcStart`, so the start has
* to be resolved in that zone and shown in the user's: read as a device
* time, it showed the organizer's clock without saying so (webmail #1252).
* An all-day event gets its last day (the end is exclusive) or none for a
* single day; a floating time stays as written.
*/
export function getInvitationDisplayRange(
event: Partial<Pick<CalendarEvent, 'start' | 'duration' | 'timeZone' | 'showWithoutTime' | 'utcStart' | 'utcEnd'>>,
): InvitationDisplayRange | null {
if (!event.start) return null;
const durationMs = parseDuration(event.duration);
if (event.showWithoutTime) {
const start = parseLocalDateTime(event.start.slice(0, 10));
if (isNaN(start.getTime())) return null;
const days = Math.round(durationMs / 86_400_000);
if (days <= 1) return { start, end: null };
const last = new Date(start);
last.setDate(last.getDate() + days - 1);
return { start, end: last };
}
let instant: Date | null = null;
if (event.utcStart) instant = new Date(event.utcStart);
else if (HAS_UTC_OFFSET.test(event.start)) instant = new Date(event.start);
else if (event.timeZone && isValidTimeZone(event.timeZone)) instant = localDateTimeToInstant(event.start, event.timeZone);
if (instant && !isNaN(instant.getTime())) {
let end: Date | null = event.utcEnd ? new Date(event.utcEnd) : null;
if ((!end || isNaN(end.getTime())) && durationMs > 0) end = new Date(instant.getTime() + durationMs);
return {
start: toDisplayDate(instant),
end: end && !isNaN(end.getTime()) && end.getTime() > instant.getTime() ? toDisplayDate(end) : null,
instant,
};
}
// Floating: the same wall clock everywhere.
const start = parseLocalDateTime(event.start);
if (isNaN(start.getTime())) return null;
return { start, end: durationMs > 0 ? new Date(start.getTime() + durationMs) : null };
}
/** The short name of the user's calendar zone at `instant` ("CET", "GMT+1"), or null. */
export function invitationZoneLabel(instant: Date, locale?: string): string | null {
try {
const part = new Intl.DateTimeFormat(locale, { timeZone: getEffectiveTimeZone(), timeZoneName: 'short' })
.formatToParts(instant)
.find((p) => p.type === 'timeZoneName');
return part?.value ?? null;
} catch {
return null;
}
}