diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index a010d1c90..f048e956a 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -122,6 +122,7 @@ vi.mock('next-intl', () => ({ decline: 'Decline', select_calendar: 'Select calendar', participant_limit: 'The server allows at most {max} participants per event.', + answered_accepted: 'You accepted on {date}.', }; let message = strings[key] ?? key; @@ -132,6 +133,7 @@ vi.mock('next-intl', () => ({ } return message; }, + useLocale: () => 'en-US', useFormatter: () => ({ dateTime: (date: Date) => date.toISOString(), }), @@ -389,6 +391,46 @@ describe('CalendarInvitationBanner', () => { expect(mocks.pushMock).toHaveBeenCalledWith('/calendar'); }); + // pim-23: an answered invitation kept asking "Respond to let the + // organizer know"; it now says what the user answered, and when. + it('says when the user accepted instead of asking for an answer', async () => { + const participants = { + attendee: { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'accepted' }, + }; + mocks.calendarState.events = [ + { id: 'event-1', uid: 'uid-1', start: '2026-03-20T09:00:00Z', updated: '2026-03-18T12:00:00Z', participants }, + ]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { uid: 'uid-1', title: 'Team Sync', start: '2026-03-20T09:00:00Z', participants }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('You accepted on Wed, Mar 18.')).toBeInTheDocument(); + }); + expect(screen.queryByText('request_info')).not.toBeInTheDocument(); + }); + + it('still asks for an answer while the invitation is unanswered', async () => { + const participants = { + attendee: { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' }, + }; + mocks.calendarState.events = [ + { id: 'event-1', uid: 'uid-1', start: '2026-03-20T09:00:00Z', updated: '2026-03-18T12:00:00Z', participants }, + ]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { uid: 'uid-1', title: 'Team Sync', start: '2026-03-20T09:00:00Z', participants }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('request_info')).toBeInTheDocument(); + }); + expect(screen.queryByText(/^You accepted/)).not.toBeInTheDocument(); + }); + it('keeps the invitation summary visible after importing', async () => { mocks.calendarState.importEvents.mockResolvedValue(1); mocks.clientMock.parseCalendarEvents.mockResolvedValue([ diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5a14eb71d..acb8092b6 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -19,7 +19,7 @@ import { ChevronDown, ChevronUp, } from '@/components/icons'; -import { useTranslations, useFormatter } from 'next-intl'; +import { useTranslations, useLocale } from 'next-intl'; import { useRouter } from '@/i18n/navigation'; import { isDocumentRTL } from '@/i18n/direction'; import { useAuthStore } from '@/stores/auth-store'; @@ -35,6 +35,8 @@ import { getInvitationTrustAssessment, formatEventSummary, formatInvitationSchedule, + invitationAnsweredAt, + invitationAnsweredKey, findParticipantByEmails, extractMethodFromRawIcs, type EventSummary, @@ -50,6 +52,7 @@ import { sanitizeColor } from '@/components/calendar/event-card'; import { RecipientPopover } from './recipient-popover'; import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; import { useAccountOwner } from '@/components/calendar/answer-bars'; +import { resolveTimeZone } from '@/lib/timezone'; interface InvitationChangeItem { label: string; @@ -377,8 +380,9 @@ type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error'; export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) { const t = useTranslations('email_viewer.calendar_invitation'); + const tEvent = useTranslations('ux_pim_event'); const describeError = useCalendarErrorMessage(); - const format = useFormatter(); + const locale = useLocale(); const router = useRouter(); const client = useAuthStore((s) => s.client); const getClientForAccount = useAuthStore((s) => s.getClientForAccount); @@ -440,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp ? { sentBy: `mailto:${identityEmails[0]}` } : undefined; const timeFormat = useSettingsStore((s) => s.timeFormat); + // The user's zone, as getEffectiveTimeZone resolves it. + const userTimeZone = resolveTimeZone(useSettingsStore((s) => s.timeZone)); const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ calendars: state.calendars, supportsCalendar: state.supportsCalendar, @@ -454,6 +460,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const [state, setState] = useState('loading'); const [parsedEvent, setParsedEvent] = useState | null>(null); const [rsvpStatus, setRsvpStatus] = useState(null); + const [answeredAt, setAnsweredAt] = useState(null); const [actionNotice, setActionNotice] = useState(null); const [actionError, setActionError] = useState(null); const [isProcessing, setIsProcessing] = useState(false); @@ -652,7 +659,23 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const senderAddress = email.from?.[0]?.email?.toLowerCase(); const userIsSender = Boolean(senderAddress && identityEmails.includes(senderAddress)); const bannerTitle = getBannerTitle(t, method); - const bannerInfo = getBannerInfo(t, method, userIsOrganizer, supportsCalendar); + // Once answered, the banner says so instead of asking (pim-23). + const answeredDate = myParticipant + ? invitationAnsweredAt( + rsvpStatus ? answeredAt : null, + myParticipant.participant, + currentUserParticipant ? existingEvent?.updated : null, + ) + : null; + const answeredKey = myParticipant && supportsCalendar ? invitationAnsweredKey(method, currentRsvp, !!answeredDate) : null; + const answeredInfo = answeredKey + ? tEvent(answeredKey, { + date: answeredDate + ? new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: userTimeZone }).format(answeredDate) + : '', + }) + : null; + const bannerInfo = answeredInfo ?? getBannerInfo(t, method, userIsOrganizer, supportsCalendar); const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method, identityEmails) : null; const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null; const participationLabel = getParticipationLabel(t, currentRsvp); @@ -735,6 +758,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // 'stopped': a plugin stopped it and said why; keep the old answer. if (outcome === 'answered') { setRsvpStatus(status); + setAnsweredAt(new Date()); setActionNotice(t('rsvp_sent')); setState('parsed'); } else if (outcome === 'added') { @@ -794,11 +818,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }; - const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule( - eventSummary, - (date, options) => format.dateTime(date, options), - timeFormat === '12h', - ); + const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule(eventSummary, { + locale, + timeZone: userTimeZone, + hour12: timeFormat === '12h', + }); const proposedChanges = method === 'counter' && proposalTarget && parsedEvent ? buildInvitationChangeItems(t, proposalTarget, parsedEvent, formatSchedule) @@ -996,7 +1020,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {summary.start && ( - + {formatSchedule(summary)} diff --git a/lib/__tests__/calendar-invitation.test.ts b/lib/__tests__/calendar-invitation.test.ts index 50f6be68d..5c6cb6351 100644 --- a/lib/__tests__/calendar-invitation.test.ts +++ b/lib/__tests__/calendar-invitation.test.ts @@ -655,9 +655,10 @@ describe('invitation times across time zones (#1252)', () => { 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); + // The banner's options for a user in `timeZone`. Intl writes ranges with + // thin spaces around the dash; the expectations use plain ones. + const formatIn = (timeZone: string, hour12 = false) => ({ locale: 'en-US', timeZone, hour12 }); + const plain = (text: string) => text.replace(/[\u2009\u202f]/g, ' '); // CalendarEvent/parse output for the Exchange invite in the issue. const londonInvite: Partial = { @@ -674,12 +675,27 @@ describe('invitation times across time zones (#1252)', () => { expect(summary.isFloating).toBe(false); }); - it('shows the invite in the viewer zone with a zone label', () => { + it('shows the invite in the viewer zone, the date once, and its own times after it (pim-23)', () => { 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'); + expect(plain(formatInvitationSchedule(summary, formatIn('Europe/Warsaw')))) + .toBe('Tue, Oct 20, 16:00 – 16:25 (15:00 – 15:25 London)'); + expect(plain(formatInvitationSchedule(summary, formatIn('America/New_York', true)))) + .toBe('Tue, Oct 20, 10:00 – 10:25 AM (3:00 – 3:25 PM London)'); + }); + + it('shows no zone for an invite on the viewer clock (pim-23)', () => { + const berlin = formatEventSummary({ title: 'Sprint planning', start: '2026-11-05T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H30M' }); + expect(plain(formatInvitationSchedule(berlin, formatIn('Europe/Berlin')))).toBe('Thu, Nov 5, 10:00 – 11:30'); + // Another zone with the same clock that day says nothing either. + expect(plain(formatInvitationSchedule(berlin, formatIn('Europe/Paris')))).toBe('Thu, Nov 5, 10:00 – 11:30'); + }); + + it('gives the event zone its date when its day there is another (pim-23)', () => { + const tokyo = formatEventSummary({ start: '2026-11-06T08:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); + expect(plain(formatInvitationSchedule(tokyo, formatIn('Europe/Berlin')))) + .toBe('Fri, Nov 6, 00:00 – 01:00 (08:00 – 09:00 Tokyo)'); + expect(plain(formatInvitationSchedule(tokyo, formatIn('America/New_York')))) + .toBe('Thu, Nov 5, 18:00 – 19:00 (Fri, Nov 6, 08:00 – 09:00 Tokyo)'); }); it('treats a UTC start as UTC', () => { @@ -698,14 +714,14 @@ describe('invitation times across time zones (#1252)', () => { 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'); + expect(plain(formatInvitationSchedule(summary, formatIn('Asia/Tokyo')))) + .toBe('Tue, Oct 20, 15:00 – 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'); + expect(formatInvitationSchedule(oneDay, formatIn('America/Los_Angeles'))).toBe('Mon, Mar 16'); + expect(formatInvitationSchedule(oneDay, formatIn('Pacific/Auckland'))).toBe('Mon, Mar 16'); const threeDays = formatEventSummary({ start: '2026-03-16T00:00:00', @@ -713,7 +729,7 @@ describe('invitation times across time zones (#1252)', () => { 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'); + expect(plain(formatInvitationSchedule(threeDays, formatIn('America/Los_Angeles')))).toBe('Mon, Mar 16 – Wed, Mar 18'); }); it('shows only the start for a zero-duration event', () => { @@ -723,8 +739,8 @@ describe('invitation times across time zones (#1252)', () => { 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'); + expect(plain(formatInvitationSchedule(summary, formatIn('Europe/Warsaw')))) + .toBe('Tue, Oct 20, 16:00 (15:00 London)'); }); it('still prefers the server utcStart/utcEnd when present', () => { @@ -733,7 +749,7 @@ describe('invitation times across time zones (#1252)', () => { 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'); + expect(plain(formatInvitationSchedule(summary, formatIn('UTC')))) + .toBe('Tue, Oct 20, 14:00 – 14:25 (15:00 – 15:25 London)'); }); }); diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index 71b94fa51..60fdff12b 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -1,7 +1,7 @@ import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPart } from '@/lib/jmap/types'; import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; import { hasAlignedAuthenticationPass } from '@/lib/email-headers'; -import { fromWallClock, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone'; +import { fromWallClock, getTimeZoneOffsetMs, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone'; export type InvitationMethod = | 'publish' @@ -477,6 +477,8 @@ export interface EventSummary { isAllDay: boolean; /** Timed event without a usable time zone: start/end are wall-clock times to show as they are. */ isFloating: boolean; + /** The zone a timed event is in, when Intl knows it; null otherwise. */ + timeZone?: string | null; location: string | null; organizer: string | null; organizerEmail: string | null; @@ -533,6 +535,9 @@ export function formatEventSummary(event: Partial): EventSummary end, isAllDay, isFloating, + timeZone: !isAllDay && !isFloating && normalizedEvent.timeZone && isValidTimeZone(normalizedEvent.timeZone) + ? normalizedEvent.timeZone + : null, location, organizer, organizerEmail, @@ -691,37 +696,100 @@ export function getInvitationDisplayRange( 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'; -}; +/** How the banner writes times: the app's language, the user's zone and clock. */ +export interface InvitationFormatOptions { + locale: string; + /** The user's zone (getEffectiveTimeZone). */ + timeZone: string; + hour12: boolean; +} + +function formatterFor(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + try { + return new Intl.DateTimeFormat(locale, options); + } catch { + return new Intl.DateTimeFormat('en', options); + } +} + +type RangeFormatter = Intl.DateTimeFormat & { formatRange?: (start: Date, end: Date) => string }; + +/** `start` to `end` as Intl writes a range: the date once when both are on one day ("Thu, Nov 5, 10:00 – 11:30"). */ +function formatRange(formatter: Intl.DateTimeFormat, start: Date, end: Date | null): string { + if (!end) return formatter.format(start); + const ranged = formatter as RangeFormatter; + if (typeof ranged.formatRange === 'function') return ranged.formatRange(start, end); + return `${formatter.format(start)} – ${formatter.format(end)}`; +} /** - * 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. + * The banner's "when" line (pim-23): on the user's clock, the date once for + * an event within one day ("Thu, Nov 5, 10:00 – 11:30", Intl's range + * format). A timed event whose zone's clock reads differently from the + * user's gets its own times after it: "Thu, Nov 5, 16:00 – 17:30 (10:00 – + * 11:30 New York)". An event in the user's zone shows no zone: "GMT+1" on + * every invitation told nobody anything. All-day and floating events are a + * wall clock, the same everywhere. */ export function formatInvitationSchedule( - summary: Pick, - formatDate: (date: Date, options: InvitationDateFormatOptions) => string, - hour12: boolean, + summary: Pick & { timeZone?: string | null }, + options: InvitationFormatOptions, ): string { const range = getInvitationDisplayRange(summary); if (!range) return summary.start ?? ''; - const base: InvitationDateFormatOptions = summary.isAllDay + const wallClock = range.start.wallClock; + const parts: Intl.DateTimeFormatOptions = 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; + : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: options.hour12 }; + const zone = wallClock ? 'UTC' : options.timeZone; + const text = formatRange(formatterFor(options.locale, { ...parts, timeZone: zone }), range.start.date, range.end?.date ?? null); + if (wallClock || summary.isAllDay || !summary.timeZone || !isValidTimeZone(summary.timeZone)) return text; + + const eventZone = summary.timeZone; + const start = range.start.date; + if (getTimeZoneOffsetMs(start, eventZone) === getTimeZoneOffsetMs(start, options.timeZone)) return text; + // Its own times: without the date when its day there is the user's day. + const day = (z: string) => formatterFor('en-CA', { timeZone: z, year: 'numeric', month: '2-digit', day: '2-digit' }).format(start); + const own: Intl.DateTimeFormatOptions = day(eventZone) === day(options.timeZone) + ? { hour: 'numeric', minute: '2-digit', hour12: options.hour12 } + : parts; + const ownText = formatRange(formatterFor(options.locale, { ...own, timeZone: eventZone }), start, range.end?.date ?? null); + const city = (eventZone.split('/').pop() || eventZone).replace(/_/g, ' '); + return `${text} (${ownText} ${city})`; +} + +/** + * The ux_pim_event key of the banner's line once the user has answered an + * invitation (pim-23), "You accepted on {date}." in place of the prompt to + * answer; null while it is unanswered, and for any other message. + */ +export function invitationAnsweredKey( + method: InvitationMethod, + status: string | null | undefined, + withDate: boolean, +): string | null { + if (method !== 'request') return null; + if (status !== 'accepted' && status !== 'declined' && status !== 'tentative') return null; + return `answered_${status}${withDate ? '' : '_nodate'}`; +} + +/** + * When the user answered: just now in this banner, else the time the server + * keeps for the answer (scheduleUpdated), else the last change of their copy + * of the event, which an answer is. Null when none of them is known. + */ +export function invitationAnsweredAt( + answeredNow: Date | null, + participant: Pick | null | undefined, + copyUpdated: string | null | undefined, +): Date | null { + if (answeredNow) return answeredNow; + for (const value of [participant?.scheduleUpdated, copyUpdated]) { + if (!value) continue; + const date = new Date(value); + if (!Number.isNaN(date.getTime())) return date; + } + return null; } /**