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;
}
/**