mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user