fix: show an invitation's date once, its zone only when it differs, and the answer once given

Changed assertions: the schedule tests in calendar-invitation.test now
expect Intl's range format ("Tue, Oct 20, 16:00 – 16:25") with the
event's own times after it when its zone's clock differs, instead of
the repeated date and "GMT+2". The banner test's next-intl mock gets
useLocale.
This commit is contained in:
Linus Rath
2026-11-04 09:35:37 +01:00
parent 10c5e51da1
commit 3134205260
4 changed files with 200 additions and 50 deletions
@@ -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(<CalendarInvitationBanner email={makeEmail()} />);
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(<CalendarInvitationBanner email={makeEmail()} />);
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([
@@ -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<BannerState>('loading');
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
const [rsvpStatus, setRsvpStatus] = useState<string | null>(null);
const [answeredAt, setAnsweredAt] = useState<Date | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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 && (
<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">
<span className="truncate" data-testid="invitation-when">
{formatSchedule(summary)}
</span>
</span>
+33 -17
View File
@@ -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<CalendarEvent> = {
@@ -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)');
});
});
+92 -24
View File
@@ -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<CalendarEvent>): 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<EventSummary, 'start' | 'end' | 'isAllDay'>,
formatDate: (date: Date, options: InvitationDateFormatOptions) => string,
hour12: boolean,
summary: Pick<EventSummary, 'start' | 'end' | 'isAllDay'> & { 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<CalendarParticipant, 'scheduleUpdated'> | 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;
}
/**