From 0fb7c8cd096eb07f746ecd330a03453d957b84df Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:26:00 +0100 Subject: [PATCH] fix: show an invitation's time in the user's time zone, with its name --- .../email/CalendarInvitationBanner.tsx | 33 ++++++--- src/lib/__tests__/calendar-invitation.test.ts | 43 ++++++++++- src/lib/calendar-invitation.ts | 74 +++++++++++++++++++ 3 files changed, 140 insertions(+), 10 deletions(-) diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 7e490753..52c94a1d 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -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; diff --git a/src/lib/__tests__/calendar-invitation.test.ts b/src/lib/__tests__/calendar-invitation.test.ts index 43fdb758..9ad6bff1 100644 --- a/src/lib/__tests__/calendar-invitation.test.ts +++ b/src/lib/__tests__/calendar-invitation.test.ts @@ -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: { diff --git a/src/lib/calendar-invitation.ts b/src/lib/calendar-invitation.ts index 9a82a344..3c36734e 100644 --- a/src/lib/calendar-invitation.ts +++ b/src/lib/calendar-invitation.ts @@ -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>, +): 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; + } +}