feat: add, update and remove bookings from mail in the calendar

This commit is contained in:
Linus Rath
2026-10-31 04:49:41 +01:00
parent cd677e7787
commit 2055df8c21
7 changed files with 1097 additions and 4 deletions
@@ -961,4 +961,120 @@ live('live Stalwart calendar', () => {
}
}, 60_000);
});
describe('a booking mail in the calendar: added, moved by a later mail, removed by a cancellation', () => {
// A train booking 2.5 months ahead (within what the cards accept), from a
// sender whose Authentication-Results pass. Imported, the headers stay
// as written, which is enough for the trust gate (see receipt.ts).
const reference = `R${RUN.slice(-8).toUpperCase()}`;
const imported: string[] = [];
let uid = '';
function bookingMail(departure: string, arrival: string, status = 'ReservationConfirmed'): string {
const markup = {
'@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: reference,
reservationStatus: `http://schema.org/${status}`, underName: { '@type': 'Person', name: 'Erika Mustermann' },
reservationFor: {
'@type': 'TrainTrip', trainNumber: 'ICE 577', provider: { '@type': 'Organization', name: 'Rail Co' },
departureStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { addressLocality: 'Hamburg', addressCountry: 'DE' } },
departureTime: departure,
arrivalStation: { '@type': 'TrainStation', name: 'Berlin Hbf', address: { addressLocality: 'Berlin', addressCountry: 'DE' } },
arrivalTime: arrival,
},
};
return [
'Received: from mail.rail.example (mail.rail.example [192.0.2.7]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1',
'Authentication-Results: mx.example.org; dkim=pass header.d=rail.example header.s=s1; spf=pass smtp.mailfrom=bounce@rail.example; dmarc=pass header.from=rail.example',
'From: Rail Co <booking@rail.example>',
`To: ${USER}`,
`Subject: ${RUN} booking ${reference} ${status}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <${RUN}-${status}-${departure.slice(11, 13)}@rail.example>`,
'MIME-Version: 1.0',
'Content-Type: text/html; charset=utf-8',
'',
`<html><head><script type="application/ld+json">${JSON.stringify(markup)}</script></head><body><p>Your booking ${reference}</p></body></html>`,
'',
].join('\r\n');
}
/** Import a booking mail, read it as the reader does and hand back its one card. */
async function receive(message: string) {
const [{ uploadBytes }, mail, sm, trust] = await Promise.all([
import('../blob'), import('../email'), import('../../lib/structured-mail'), import('../../lib/structured-mail/trust'),
]);
const [[, boxes]] = await raw(client, [['Mailbox/get', { accountId: client.accountId, properties: ['id', 'role'] }, '0']], MAIL_USING);
const inbox = (boxes.list as { id: string; role?: string }[]).find((b) => b.role === 'inbox')!.id;
const { blobId } = await uploadBytes(new TextEncoder().encode(message), 'message/rfc822');
const id = await mail.importEmailBlob(blobId, inbox, { $seen: true });
imported.push(id);
const email = await mail.getFullEmail(id);
expect(trust.getStructuredMailTrust(email)).toEqual({ trusted: true });
const cards = sm.resolveCards(sm.readStructuredMail(trust.structuredHtmlPart(email)!.value, { mailDate: new Date(email.sentAt ?? email.receivedAt) }));
expect(cards).toHaveLength(1);
return { email, card: cards[0] };
}
/** The booking calendar hook of the card, settled on `status`. */
async function cardCalendar(message: string, status: string) {
const [{ useBookingCalendar }, harness] = await Promise.all([
import('../../components/email/structured-mail/use-booking-calendar'),
import('../../components/email/structured-mail/__tests__/render-native'),
]);
const { email, card } = await receive(message);
const hook = harness.renderHook(() => useBookingCalendar(card, email));
await harness.waitFor(() => expect(hook.current.status).toBe(status), { timeout: 15_000 });
return hook;
}
beforeAll(async () => {
await useCalendarStore.getState().fetchCalendars();
}, 30_000);
afterAll(async () => {
const c = await singleton();
if (imported.length > 0) {
await raw(c, [['Email/set', { accountId: c.accountId, destroy: imported }, '0']], MAIL_USING).catch(() => undefined);
}
if (uid) {
const { findEventsByUid } = await import('../calendar');
const left = await findEventsByUid(uid).catch(() => [] as CalendarEvent[]);
if (left.length > 0) await deleteEvents(left.map((e) => e.id)).catch(() => undefined);
}
}, 60_000);
it('adds the booking, moves its event for the changed mail and removes it for the cancellation', async () => {
const { findEventsByUid } = await import('../calendar');
const first = await cardCalendar(bookingMail('2027-01-15T08:46:00+01:00', '2027-01-15T12:50:00+01:00'), 'add');
await first.current.add();
expect(first.current.error).toBeNull();
expect(first.current.status).toBe('in-calendar');
const created = useCalendarStore.getState().events.find((e) => e.description?.includes(reference));
expect(created, 'the store holds the new event').toBeTruthy();
uid = created!.uid;
let onServer = await findEventsByUid(uid);
expect(onServer).toHaveLength(1);
expect(onServer[0]).toMatchObject({ start: '2027-01-15T08:46:00', timeZone: 'Europe/Berlin', duration: 'PT4H4M' });
first.unmount();
// A later mail moves the train by two hours: same booking, same uid.
const moved = await cardCalendar(bookingMail('2027-01-15T10:46:00+01:00', '2027-01-15T14:50:00+01:00'), 'update');
await moved.current.update();
expect(moved.current.error).toBeNull();
expect(moved.current.status).toBe('in-calendar');
onServer = await findEventsByUid(uid);
expect(onServer).toHaveLength(1);
expect(onServer[0]).toMatchObject({ start: '2027-01-15T10:46:00', timeZone: 'Europe/Berlin', duration: 'PT4H4M' });
moved.unmount();
// The cancellation takes it out.
const cancelled = await cardCalendar(bookingMail('2027-01-15T10:46:00+01:00', '2027-01-15T14:50:00+01:00', 'ReservationCancelled'), 'remove');
await cancelled.current.remove();
expect(cancelled.current.error).toBeNull();
expect(cancelled.current.status).toBe('cancelled');
expect(await findEventsByUid(uid)).toEqual([]);
cancelled.unmount();
}, 90_000);
});
});
@@ -3,10 +3,12 @@
// same pieces in email-viewer.tsx).
import React from 'react';
import type { Email } from '../../../api/types';
import type { Booking } from '../../../lib/structured-mail';
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../../../lib/trusted-senders';
import { useHasContacts } from '../../../lib/capabilities';
import { useContactsStore } from '../../../stores/contacts-store';
import { useSettingsStore } from '../../../stores/settings-store';
import { BookingCalendarActions } from './booking-calendar-actions';
import { StructuredMailCards } from './structured-mail-cards';
import { useStructuredMail } from './use-structured-mail';
@@ -20,7 +22,8 @@ interface Props {
* Cards from the message's schema.org markup and Wallet passes, for a sender
* the receiving server verified. Nothing for any other message. Product
* images load only when the body's remote content would load without a tap:
* the policy allows it or the sender is trusted.
* the policy allows it or the sender is trusted. Each booking card offers to
* add, update or remove its events in the calendar.
*/
export function StructuredMailBanner({ email, jmapAccountId }: Props) {
const cards = useStructuredMail(email, jmapAccountId);
@@ -40,6 +43,10 @@ export function StructuredMailBanner({ email, jmapAccountId }: Props) {
// trustedSenders changes what isSenderTrusted answers.
[policy, sender, isSenderTrusted, trustedSenders, trustedSendersAddressBook, hasContacts, trustedBookEmails], // eslint-disable-line react-hooks/exhaustive-deps
);
const calendarActions = React.useCallback(
(booking: Booking) => <BookingCalendarActions booking={booking} email={email} />,
[email],
);
if (cards.length === 0) return null;
return <StructuredMailCards cards={cards} allowRemoteImages={allowRemoteImages} />;
return <StructuredMailCards cards={cards} allowRemoteImages={allowRemoteImages} calendarActions={calendarActions} />;
}
@@ -307,7 +307,8 @@ export function renderHook<T>(hook: () => T): HookResult<T> {
/** Wait until `check` passes (it throws until then), flushing promises and timers in between. */
export async function waitFor(check: () => void, { timeout = 2000 } = {}): Promise<void> {
const started = Date.now();
// performance.now: tests fake Date.
const started = performance.now();
let last: unknown;
for (;;) {
try {
@@ -316,7 +317,7 @@ export async function waitFor(check: () => void, { timeout = 2000 } = {}): Promi
} catch (err) {
last = err;
}
if (Date.now() - started > timeout) throw last;
if (performance.now() - started > timeout) throw last;
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
@@ -7,6 +7,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock());
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(async () => true) }));
// The calendar row has its own tests (use-booking-calendar.test.ts); here only
// whether each booking gets one.
vi.mock('../booking-calendar-actions', async () => {
const { createElement: h } = await import('react');
return { BookingCalendarActions: ({ booking }: { booking: { kind: string } }) => h('Text', { testID: 'calendar-actions' }, booking.kind) };
});
import type { Email } from '../../../../api/types';
import { useSettingsStore } from '../../../../stores/settings-store';
@@ -46,6 +52,20 @@ describe('StructuredMailBanner', () => {
expect(render(createElement(StructuredMailBanner, { email: mail(PASS, 'Fwd: Your order') })).children).toEqual([]);
});
it('gives a booking card the calendar row, and an order none', () => {
const train = {
'@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: 'ZUG1',
reservationFor: {
'@type': 'TrainTrip', trainNumber: 'ICE 1', departureStation: { name: 'Hamburg Hbf' }, departureTime: '2026-12-05T08:46:00+01:00',
arrivalStation: { name: 'Berlin Hbf' }, arrivalTime: '2026-12-05T10:50:00+01:00',
},
};
const email = mail(PASS);
email.bodyValues = { '1': { value: `<script type="application/ld+json">${JSON.stringify(train)}</script>` } };
expect(byTestId(render(createElement(StructuredMailBanner, { email })), 'calendar-actions').map(textOf)).toEqual(['train']);
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'calendar-actions')).toHaveLength(0);
});
it('loads product images only when remote content would load', () => {
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] });
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
@@ -0,0 +1,404 @@
// Port of webmail components/email/structured-mail/__tests__/
// use-booking-calendar.test.tsx at 3aac5a5c4, against the app's calendar
// store (its write functions swapped for mocks) and api/calendar's
// findEventsByUid (mocked). The webmail's plugin "blocked" cases and its
// keyboard and viewport cases for the floating picker have no app
// counterpart; the account switch cases are the app's own.
import { createElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock());
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
const findEventsByUid = vi.fn();
vi.mock('../../../../api/calendar', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../api/calendar')>()),
findEventsByUid: (uid: string) => findEventsByUid(uid),
}));
const navigate = vi.fn();
vi.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate }) }));
import type { Calendar, CalendarEvent, Email } from '../../../../api/types';
import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard } from '../../../../lib/structured-mail';
import { bookingToEvents } from '../../../../lib/structured-mail/calendar';
import { usePendingCalendarOpen } from '../../../../navigation/pending-calendar-open';
import { CalendarAccountChangedError, useCalendarStore } from '../../../../stores/calendar-store';
import { useCalendarSubscriptionsStore } from '../../../../stores/calendar-subscriptions-store';
import { BookingCalendarActions } from '../booking-calendar-actions';
import { resetRemovedBookingUids, useBookingCalendar } from '../use-booking-calendar';
import { byTestId, findAll, render, renderHook, textOf, waitFor, type HostNode } from './render-native';
const ctx = 'http://schema.org';
const NOW = new Date('2026-11-01T12:00:00Z');
const EMAIL = { from: [{ email: 'booking@mail.lufthansa.com' }], subject: 'Your booking QX7K2M' } as unknown as Email;
function leg(number: string, dep: [string, string], arr: [string, string], status?: string) {
return {
'@context': ctx, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann',
...(status ? { reservationStatus: `http://schema.org/${status}` } : {}),
reservationFor: {
'@type': 'Flight', flightNumber: number, airline: { name: 'Lufthansa', iataCode: 'LH' },
departureAirport: { iataCode: dep[0] }, departureTime: dep[1],
arrivalAirport: { iataCode: arr[0] }, arrivalTime: arr[1],
},
};
}
const OUT = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00']);
const OUT_MOVED = leg('400', ['FRA', '2026-12-01T12:40:00+01:00'], ['JFK', '2026-12-01T15:35:00-05:00']);
const BACK = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00']);
const BACK_CANCELLED = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00'], 'ReservationCancelled');
const OUT_CANCELLED = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00'], 'ReservationCancelled');
async function bookingFrom(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise<Booking> {
const html = `<html><head>${items.map((i) => `<script type="application/ld+json">${JSON.stringify(i)}</script>`).join('')}</head></html>`;
const cards = resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones());
expect(cards).toHaveLength(1);
return cards[0] as Booking;
}
/** CalendarEvent/get's shape for an event made from `wanted`: id, calendar, server defaults. */
function stored(wanted: Partial<CalendarEvent>, id: string, calendarId = 'cal-default'): CalendarEvent {
const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([key, loc]) => [key, { ...loc, iCalendar: { name: 'vlocation' } }]));
return {
...wanted, id, calendarIds: { [calendarId]: true }, utcStart: '2026-12-01T09:15:00Z', participants: null,
description: 'whatever the server keeps', locations,
} as unknown as CalendarEvent;
}
const rights = (write: boolean) => ({ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: write, mayWriteOwn: write, mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: write });
const cal = (id: string, extra: Partial<Calendar> = {}): Calendar => ({
id, name: id, color: '#3366ff', isDefault: false, isSubscribed: true, isVisible: true, sortOrder: 0, description: null,
shareWith: null, myRights: rights(true), ...extra,
});
const createEvent = vi.fn();
const updateEvent = vi.fn();
const deleteEvent = vi.fn();
const initialCalendar = useCalendarStore.getState();
const initialSubscriptions = useCalendarSubscriptionsStore.getState();
/** The calendar holds these events, found by uid. */
function calendarHolds(events: CalendarEvent[]) {
findEventsByUid.mockImplementation(async (uid: string) => events.filter((e) => e.uid === uid));
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(NOW);
findEventsByUid.mockReset();
calendarHolds([]);
resetRemovedBookingUids();
createEvent.mockReset().mockImplementation(async (event: Partial<CalendarEvent>, calendarId: string) => {
const created = { ...event, id: `new-${event.uid}`, calendarIds: { [calendarId]: true } } as CalendarEvent;
useCalendarStore.setState((s) => ({ events: [...s.events, created] }));
return created;
});
updateEvent.mockReset().mockImplementation(async (id: string, patch: Partial<CalendarEvent>) => {
useCalendarStore.setState((s) => ({ events: s.events.map((e) => (e.id === id ? { ...e, ...patch } : e)) }));
});
deleteEvent.mockReset().mockResolvedValue(undefined);
navigate.mockReset();
useCalendarStore.setState({
calendars: [cal('cal-default', { isDefault: true })],
events: [],
taskOnlyCalendarIds: [],
createEvent, updateEvent, deleteEvent,
});
useCalendarSubscriptionsStore.setState({ subscriptions: [] });
});
afterEach(() => {
vi.useRealTimers();
useCalendarStore.setState(initialCalendar, true);
useCalendarSubscriptionsStore.setState(initialSubscriptions, true);
usePendingCalendarOpen.setState({ target: null });
});
async function settled(card: StructuredCard | null) {
const hook = renderHook(() => useBookingCalendar(card, EMAIL));
await waitFor(() => expect(hook.current.status).not.toBe('loading'));
return hook;
}
describe('useBookingCalendar', () => {
it('offers to add a booking the calendar lacks, and adds each live leg once', async () => {
const booking = await bookingFrom([OUT, BACK]);
const hook = await settled(booking);
expect(hook.current.status).toBe('add');
expect(findEventsByUid).toHaveBeenCalledTimes(2);
await hook.current.add('cal-default');
expect(createEvent).toHaveBeenCalledTimes(2);
const [first, calendarId] = createEvent.mock.calls[0];
const wanted = bookingToEvents(booking, { senderDomain: 'lufthansa.com' })[0];
expect(calendarId).toBe('cal-default');
expect(first).toMatchObject({
uid: wanted.uid, title: 'LH 400 FRA → JFK',
start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT8H50M',
});
expect(first).not.toHaveProperty('participants');
// No options: never scheduling messages.
expect(createEvent.mock.calls[0]).toHaveLength(2);
expect(hook.current.status).toBe('in-calendar');
expect(hook.current.notice).toBe('added');
});
it('says the booking is in the calendar when every leg is there unchanged', async () => {
const booking = await bookingFrom([OUT, BACK]);
const [a, b] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' });
calendarHolds([stored(a, 'e1'), stored(b, 'e2')]);
const hook = await settled(booking);
expect(hook.current.status).toBe('in-calendar');
expect(useCalendarStore.getState().events.map((e) => e.id).sort()).toEqual(['e1', 'e2']);
});
it('updates the found event after a schedule change and never creates a second one', async () => {
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
calendarHolds([stored(old, 'e1')]);
const hook = await settled(await bookingFrom([OUT_MOVED]));
expect(hook.current.status).toBe('update');
await hook.current.update();
expect(createEvent).not.toHaveBeenCalled();
expect(updateEvent).toHaveBeenCalledTimes(1);
const [id, patch, options] = updateEvent.mock.calls[0];
expect(id).toBe('e1');
expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' });
// The stored description isn't the generated one any more: title and notes stay the user's.
expect(patch).not.toHaveProperty('title');
expect(patch).not.toHaveProperty('description');
expect(options).toBeUndefined();
expect(hook.current.status).toBe('in-calendar');
});
it('adds a missing leg next to the others, in their calendar, as an update', async () => {
useCalendarStore.setState({ calendars: [cal('cal-default', { isDefault: true }), cal('cal-travel')] });
const booking = await bookingFrom([OUT, BACK]);
const [a] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' });
calendarHolds([stored(a, 'e1', 'cal-travel')]);
const hook = await settled(booking);
expect(hook.current.status).toBe('update');
await hook.current.update();
expect(createEvent).toHaveBeenCalledTimes(1);
expect(createEvent.mock.calls[0][0]).toMatchObject({ title: 'LH 401 JFK → FRA' });
expect(createEvent.mock.calls[0][1]).toBe('cal-travel');
expect(updateEvent).not.toHaveBeenCalled();
});
it('offers to remove a cancelled booking that is in the calendar, and removes it', async () => {
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
calendarHolds([stored(old, 'e1')]);
const hook = await settled(await bookingFrom([OUT_CANCELLED]));
expect(hook.current.status).toBe('remove');
await hook.current.remove();
expect(deleteEvent).toHaveBeenCalledWith('e1');
expect(createEvent).not.toHaveBeenCalled();
expect(hook.current.status).toBe('cancelled');
expect(hook.current.notice).toBe('removed');
});
it('offers nothing for a cancelled booking the calendar never had', async () => {
const hook = await settled(await bookingFrom([OUT_CANCELLED]));
expect(hook.current.status).toBe('cancelled');
});
it('never adds a cancelled leg, and removes its event along with the update', async () => {
const booking = await bookingFrom([OUT, BACK_CANCELLED]);
const hook = await settled(booking);
expect(hook.current.status).toBe('add');
await hook.current.add();
expect(createEvent).toHaveBeenCalledTimes(1);
expect(createEvent.mock.calls[0][0]).toMatchObject({ title: 'LH 400 FRA → JFK' });
const [a, b] = bookingToEvents(await bookingFrom([OUT, BACK]), { senderDomain: 'lufthansa.com' });
calendarHolds([stored(a, 'e1'), stored(b, 'e2')]);
useCalendarStore.setState({ events: [] });
createEvent.mockClear();
const second = await settled(booking);
expect(second.current.status).toBe('update');
await second.current.update();
expect(deleteEvent).toHaveBeenCalledWith('e2');
expect(createEvent).not.toHaveBeenCalled();
expect(updateEvent).not.toHaveBeenCalled();
});
it('does not find events another sender domain made', async () => {
const booking = await bookingFrom([OUT]);
const [foreign] = bookingToEvents(booking, { senderDomain: 'lufthansa-deals.com' });
calendarHolds([stored(foreign, 'e9')]);
const hook = await settled(booking);
expect(hook.current.status).toBe('add');
});
it('shows nothing for a booking that is over', async () => {
vi.setSystemTime(new Date('2026-12-02T12:00:00Z'));
expect(renderHook(() => useBookingCalendar(null, EMAIL)).current.status).toBe('hidden');
const booking = await bookingFrom([OUT]);
expect(renderHook(() => useBookingCalendar(booking, EMAIL)).current.status).toBe('hidden');
expect(findEventsByUid).not.toHaveBeenCalled();
});
it('shows nothing for shipments', () => {
const shipment: StructuredCard = { kind: 'shipment', key: 'shipment|order:1', orderNumber: '1', items: [], parcels: [], source: 'json-ld' };
expect(renderHook(() => useBookingCalendar(shipment, EMAIL)).current.status).toBe('hidden');
});
it('shows nothing when the lookup fails, rather than risk a duplicate', async () => {
findEventsByUid.mockRejectedValue(new Error('offline'));
const hook = await settled(await bookingFrom([OUT]));
expect(hook.current.status).toBe('hidden');
});
it('shows nothing to add without a calendar it may write to', async () => {
useCalendarStore.setState({ calendars: [cal('ro', { myRights: rights(false) })] });
const hook = await settled(await bookingFrom([OUT]));
expect(hook.current.status).toBe('hidden');
});
it('says why an add failed and looks the events up again', async () => {
createEvent.mockRejectedValue(new Error('Failed to create event'));
const hook = await settled(await bookingFrom([OUT]));
await hook.current.add();
expect(hook.current.error).toBe("Couldn't change your calendar. Try again.");
expect(hook.current.status).toBe('add');
await waitFor(() => expect(findEventsByUid).toHaveBeenCalledTimes(2));
});
it('shows a booking added from another card for the same booking as in the calendar', async () => {
const first = await settled(await bookingFrom([OUT]));
const second = await settled(await bookingFrom([OUT]));
expect(first.current.status).toBe('add');
expect(second.current.status).toBe('add');
await second.current.add();
expect(second.current.status).toBe('in-calendar');
await waitFor(() => expect(first.current.status).toBe('in-calendar'));
expect(createEvent).toHaveBeenCalledTimes(1);
});
it('shows a booking removed from another card as gone', async () => {
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
calendarHolds([stored(old, 'e1')]);
deleteEvent.mockImplementation(async (id: string) => {
useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) }));
});
const first = await settled(await bookingFrom([OUT_CANCELLED]));
const second = await settled(await bookingFrom([OUT_CANCELLED]));
expect(first.current.status).toBe('remove');
await second.current.remove();
await waitFor(() => expect(first.current.status).toBe('cancelled'));
});
describe('account switches', () => {
it('drops a lookup that lands after the account changed', async () => {
let answer: (events: CalendarEvent[]) => void = () => undefined;
findEventsByUid.mockImplementation(() => new Promise<CalendarEvent[]>((resolve) => { answer = resolve; }));
const hook = renderHook(() => useBookingCalendar(OUT_BOOKING!, EMAIL));
expect(hook.current.status).toBe('loading');
useCalendarStore.getState().reset();
useCalendarStore.setState({ calendars: [cal('cal-default', { isDefault: true })], createEvent, updateEvent, deleteEvent });
answer([]);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(hook.current.status).toBe('loading');
});
it('stops a multi-leg add at the switch, and says nothing failed', async () => {
const hook = await settled(await bookingFrom([OUT, BACK]));
createEvent.mockImplementationOnce(async (event: Partial<CalendarEvent>, calendarId: string) => {
const created = { ...event, id: 'new-1', calendarIds: { [calendarId]: true } } as CalendarEvent;
// The account changes while the first leg is being written.
useCalendarStore.getState().reset();
useCalendarStore.setState({ createEvent, updateEvent, deleteEvent });
return created;
});
await hook.current.add('cal-default');
// The second leg never went out to the next account.
expect(createEvent).toHaveBeenCalledTimes(1);
expect(hook.current.error).toBeNull();
});
it('shows no error when the store refuses a write the switch overtook', async () => {
createEvent.mockRejectedValue(new CalendarAccountChangedError());
const hook = await settled(await bookingFrom([OUT]));
await hook.current.add();
expect(hook.current.error).toBeNull();
expect(hook.current.notice).toBeNull();
});
});
});
let OUT_BOOKING: Booking | null = null;
beforeEach(async () => {
OUT_BOOKING ??= await bookingFrom([OUT]);
});
/** BookingCalendarActions with its own state; the tree is rendered from the latest output. */
function mountActions(booking: Booking) {
const hook = renderHook(() => BookingCalendarActions({ booking, email: EMAIL }));
return { hook, tree: (): HostNode => render(hook.current ?? createElement('View')) };
}
const buttonText = (tree: HostNode) => findAll(tree, (n) => n.type === 'Pressable').map(textOf);
describe('BookingCalendarActions', () => {
it('lists only writable event calendars of the own account, the default first', async () => {
useCalendarStore.setState({
calendars: [
cal('Work'),
cal('Personal', { isDefault: true }),
cal('Tasks'),
cal('Holidays', { myRights: rights(false) }),
cal('Team', { isShared: true, accountId: 'other' }),
cal('Feed'),
],
taskOnlyCalendarIds: ['Tasks'],
});
useCalendarSubscriptionsStore.setState({ subscriptions: [{ id: 'sub', calendarId: 'Feed' } as never] });
const { hook, tree } = mountActions(await bookingFrom([OUT]));
await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1));
byTestId(tree(), 'booking-calendar-add')[0].props.onPress();
const choices = byTestId(tree(), 'booking-calendar-choice');
expect(choices.map(textOf)).toEqual(['Personal', 'Work']);
choices[1].props.onPress();
await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1));
expect(createEvent.mock.calls[0][1]).toBe('Work');
await waitFor(() => expect(textOf(byTestId(tree(), 'booking-calendar-notice')[0])).toBe('Added to your calendar'));
hook.unmount();
});
it('adds straight away when there is one calendar to choose, then shows the pill and the way to the calendar', async () => {
const booking = await bookingFrom([OUT]);
const { tree } = mountActions(booking);
await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1));
byTestId(tree(), 'booking-calendar-add')[0].props.onPress();
expect(byTestId(tree(), 'booking-calendar-picker')).toHaveLength(0);
await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1));
expect(createEvent.mock.calls[0][1]).toBe('cal-default');
const [wanted] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' });
calendarHolds([stored(wanted, 'e1')]);
useCalendarStore.setState({ events: [] });
const again = mountActions({ ...booking });
await waitFor(() => expect(textOf(again.tree())).toContain('In your calendar'));
expect(buttonText(again.tree())).toEqual(['View in calendar']);
byTestId(again.tree(), 'booking-calendar-view')[0].props.onPress();
expect(usePendingCalendarOpen.getState().target).toMatchObject({ kind: 'event', eventId: 'e1', serverId: 'e1', startMs: Date.parse('2026-12-01T09:15:00Z') });
expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' });
});
it('shows the error under the buttons when a change fails', async () => {
createEvent.mockRejectedValue(new Error('nope'));
const { tree } = mountActions(await bookingFrom([OUT]));
await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1));
byTestId(tree(), 'booking-calendar-add')[0].props.onPress();
await waitFor(() => expect(textOf(byTestId(tree(), 'booking-calendar-error')[0])).toBe("Couldn't change your calendar. Try again."));
});
it('renders nothing for a booking that is over', async () => {
const booking = await bookingFrom([OUT]);
vi.setSystemTime(new Date('2026-12-02T12:00:00Z'));
const { hook } = mountActions(booking);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(hook.current).toBeNull();
expect(findEventsByUid).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,198 @@
// Port of webmail components/email/structured-mail/booking-calendar-actions.tsx
// at 3aac5a5c4, on React Native views. App: the calendar picker opens as a
// list under the button (CalendarInvitationBanner's picker) instead of a
// floating menu, and "View in calendar" opens the event on the Calendar tab.
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { Email } from '../../../api/types';
import { getCalendarColor } from '../../../lib/calendar-utils';
import type { StructuredCard } from '../../../lib/structured-mail';
import type { RootStackParamList } from '../../../navigation/types';
import { useLocaleStore } from '../../../stores/locale-store';
import { useColors } from '../../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
import { ArrowRight, CalendarDays, CalendarPlus, ChevronDown } from '../../icons';
import { CalendarCheck, CalendarX } from './icons';
import { useBookingCalendar } from './use-booking-calendar';
type Nav = NativeStackNavigationProp<RootStackParamList>;
/**
* The calendar part of a booking card's action row: "Add to calendar" (with
* a calendar picker when there is more than one calendar to choose from),
* "Update in calendar", "Remove from calendar", the "In your calendar" pill
* and "View in calendar". Renders nothing when the booking has no calendar
* action (see useBookingCalendar).
*/
export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick<Email, 'from' | 'subject'> }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<Nav>();
const openCalendar = React.useCallback(() => {
navigation.navigate('MainTabs', { screen: 'Calendar' } as never);
}, [navigation]);
const calendar = useBookingCalendar(booking, email, { openCalendar });
const { status, calendars, busy, error, notice } = calendar;
const [pickerOpen, setPickerOpen] = React.useState(false);
if (status === 'hidden' || status === 'loading') return null;
// A cancelled booking with nothing in the calendar has no action; only the
// confirmation of a removal just done stays.
if (status === 'cancelled' && notice !== 'removed') return null;
const onAdd = () => {
if (calendars.length <= 1) {
void calendar.add(calendars[0]?.id);
return;
}
setPickerOpen((open) => !open);
};
const noticeText = notice === 'added'
? t('structured_mail_calendar.added', 'Added to your calendar')
: notice === 'updated'
? t('structured_mail_calendar.updated', 'Calendar updated')
: notice === 'removed'
? t('structured_mail_calendar.removed', 'Removed from your calendar')
: null;
const showView = status === 'in-calendar' || status === 'update';
return (
<View style={styles.row} testID="booking-calendar-actions">
{status === 'add' ? (
<Pressable
onPress={onAdd}
disabled={busy}
style={({ pressed }) => [styles.button, pressed && styles.pressed, busy && styles.disabled]}
accessibilityRole="button"
accessibilityState={{ disabled: busy, expanded: calendars.length > 1 ? pickerOpen : undefined }}
testID="booking-calendar-add"
>
<CalendarPlus size={14} color={c.textSecondary} />
<Text style={styles.buttonText}>{t('structured_mail_calendar.add_to_calendar', 'Add to calendar')}</Text>
{calendars.length > 1 ? <ChevronDown size={12} color={c.textSecondary} /> : null}
</Pressable>
) : null}
{status === 'update' ? (
<Pressable
onPress={() => { void calendar.update(); }}
disabled={busy}
style={({ pressed }) => [styles.button, pressed && styles.pressed, busy && styles.disabled]}
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
testID="booking-calendar-update"
>
<CalendarCheck size={14} color={c.textSecondary} />
<Text style={styles.buttonText}>{t('structured_mail_calendar.update_in_calendar', 'Update in calendar')}</Text>
</Pressable>
) : null}
{status === 'remove' ? (
<Pressable
onPress={() => { void calendar.remove(); }}
disabled={busy}
style={({ pressed }) => [styles.button, pressed && styles.pressedDestructive, busy && styles.disabled]}
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
testID="booking-calendar-remove"
>
<CalendarX size={14} color={c.textSecondary} />
<Text style={styles.buttonText}>{t('structured_mail_calendar.remove_from_calendar', 'Remove from calendar')}</Text>
</Pressable>
) : null}
{status === 'in-calendar' && !noticeText ? (
<View style={styles.pill}><Text style={styles.pillText}>{t('structured_mail_calendar.in_calendar', 'In your calendar')}</Text></View>
) : null}
{noticeText ? (
<View style={styles.pill} accessibilityLiveRegion="polite" testID="booking-calendar-notice">
<Text style={styles.pillText}>{noticeText}</Text>
</View>
) : null}
{showView ? (
<Pressable
onPress={calendar.viewInCalendar}
style={({ pressed }) => [styles.link, pressed && styles.pressed]}
accessibilityRole="button"
testID="booking-calendar-view"
>
<CalendarDays size={14} color={c.textSecondary} />
<Text style={styles.buttonText}>{t('structured_mail_calendar.view_in_calendar', 'View in calendar')}</Text>
<ArrowRight size={12} color={c.textSecondary} />
</Pressable>
) : null}
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : null}
{status === 'add' && pickerOpen && calendars.length > 1 ? (
<View style={styles.picker} accessibilityLabel={t('structured_mail_calendar.select_calendar', 'Choose a calendar')} testID="booking-calendar-picker">
<Text style={styles.pickerTitle}>{t('structured_mail_calendar.select_calendar', 'Choose a calendar')}</Text>
{calendars.map((cal) => (
<Pressable
key={cal.id}
onPress={() => {
setPickerOpen(false);
void calendar.add(cal.id);
}}
style={({ pressed }) => [styles.pickerRow, pressed && styles.pressed]}
accessibilityRole="menuitem"
accessibilityLabel={cal.name}
testID="booking-calendar-choice"
>
<View style={[styles.swatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.pickerText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</View>
) : null}
{error ? (
<Text style={styles.error} accessibilityRole="alert" accessibilityLiveRegion="assertive" testID="booking-calendar-error">{error}</Text>
) : null}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 },
button: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
minHeight: 36,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
link: { flexDirection: 'row', alignItems: 'center', gap: 6, minHeight: 36, paddingHorizontal: spacing.md, borderRadius: radius.md },
pressed: { backgroundColor: c.surfaceHover },
pressedDestructive: { backgroundColor: c.errorBg, borderColor: c.errorBorder },
disabled: { opacity: 0.5 },
buttonText: { ...typography.body, color: c.textSecondary },
pill: { borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2, backgroundColor: c.successBg },
pillText: { ...typography.small, color: c.success },
picker: {
flexBasis: '100%',
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
overflow: 'hidden',
},
pickerTitle: { ...typography.captionMedium, color: c.textMuted, paddingHorizontal: spacing.md, paddingVertical: 6 },
pickerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minHeight: 40, paddingHorizontal: spacing.md },
pickerText: { ...typography.body, color: c.text, flex: 1 },
swatch: { width: 12, height: 12, borderRadius: 6 },
error: { ...typography.caption, color: c.error, flexBasis: '100%' },
});
}
@@ -0,0 +1,347 @@
// Port of webmail components/email/structured-mail/use-booking-calendar.ts
// at 3aac5a5c4. App differences:
// - lookups go through api/calendar findEventsByUid (CalendarEvent/query by
// uid in the primary account) and writes through the calendar store, whose
// account guard (writeScope) stops a write that an account switch overtook;
// an action of several writes also checks calendarOperationScope between
// them, and a lookup that lands after a switch is dropped;
// - the store's writes throw instead of answering false, and no plugin can
// stop them, so there is no "blocked" notice;
// - an add goes through sanitizeOutgoingCalendarEventData first, as the
// webmail's calendar store does on create;
// - "View in calendar" opens the event on the Calendar tab the way a tapped
// reminder does (pending-calendar-open), through the caller's navigator.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { findEventsByUid } from '../../../api/calendar';
import type { Calendar, CalendarEvent, Email } from '../../../api/types';
import { canCreateEventsIn } from '../../../lib/calendar-editability';
import { useHasCalendar } from '../../../lib/capabilities';
import {
bookingEndsAt,
bookingEventPatch,
bookingEventSlots,
eventNeedsUpdate,
senderDomainOf,
type BookingEventLabels,
type BookingEventSlot,
} from '../../../lib/structured-mail/calendar';
import { sanitizeOutgoingCalendarEventData } from '../../../lib/structured-mail/event-normalization';
import type { StructuredCard } from '../../../lib/structured-mail';
import { setPendingCalendarOpen } from '../../../navigation/pending-calendar-open';
import {
CalendarAccountChangedError,
calendarOperationScope,
calendarStoreGeneration,
useCalendarStore,
} from '../../../stores/calendar-store';
import { useCalendarSubscriptionsStore } from '../../../stores/calendar-subscriptions-store';
import { useLocaleStore } from '../../../stores/locale-store';
/**
* What the card offers for its booking:
* - `hidden`: nothing (no calendar, shipment, booking over, lookup failed)
* - `loading`: looking the events up; nothing to show yet
* - `add`: none of the booking's events is in the calendar
* - `in-calendar`: every live event is there and up to date
* - `update`: some are there, and others are missing, changed or cancelled
* - `remove`: the booking is cancelled and its events are still there
* - `cancelled`: the booking is cancelled and nothing of it is in the calendar
*/
export type BookingCalendarStatus = 'hidden' | 'loading' | 'add' | 'in-calendar' | 'update' | 'remove' | 'cancelled';
export interface BookingCalendar {
status: BookingCalendarStatus;
/** Calendars an add can go into: own account, writable, not tasks-only; the default first. */
calendars: Calendar[];
busy: boolean;
/** What went wrong with the last action, in the user's language. */
error: string | null;
/** What the last action did, for a short confirmation. */
notice: 'added' | 'updated' | 'removed' | null;
/** Add every live event, into `calendarId` or the default calendar. */
add: (calendarId?: string) => Promise<void>;
/** Add the missing live events, update the changed ones, remove the cancelled ones. */
update: () => Promise<void>;
/** Remove the events of cancelled legs or of the cancelled booking. */
remove: () => Promise<void>;
/** Open the booking's event on the Calendar tab (through `openCalendar`). */
viewInCalendar: () => void;
}
/**
* Uids whose events a card removed in this session. Another card for the
* same booking (a conversation showing two of its mails) may still hold the
* event from its own lookup; this tells it the event is gone.
*/
const removedUids = new Set<string>();
/** For tests: forget the uids removed so far. */
export function resetRemovedBookingUids(): void {
removedUids.clear();
}
type Lookup = { key: string; failed: false; events: Record<string, CalendarEvent | null> } | { key: string; failed: true };
interface Plan {
status: BookingCalendarStatus;
/** Live slots and the stored event of each (null: not in the calendar). */
live: { slot: BookingEventSlot; existing: CalendarEvent | null }[];
/** Stored events of cancelled slots. */
stale: { slot: BookingEventSlot; existing: CalendarEvent }[];
}
/** Own calendars an event can be created in, the default first. */
export function bookingTargetCalendars(
calendars: Calendar[],
isSubscriptionCalendar: (calendarId: string) => boolean,
taskOnlyCalendarIds: readonly string[],
): Calendar[] {
const own = calendars.filter((c) =>
!c.isShared
&& !taskOnlyCalendarIds.includes(c.id)
&& canCreateEventsIn(c, isSubscriptionCalendar));
const defaultCalendar = own.find((c) => c.isDefault);
return defaultCalendar ? [defaultCalendar, ...own.filter((c) => c !== defaultCalendar)] : own;
}
export function planFor(slots: BookingEventSlot[], existingOf: (uid: string) => CalendarEvent | null): Plan {
const live = slots
.filter((slot) => !slot.cancelled && slot.event)
.map((slot) => ({ slot, existing: existingOf(slot.uid) }));
const stale = slots
.filter((slot) => slot.cancelled)
.map((slot) => ({ slot, existing: existingOf(slot.uid) }))
.filter((entry): entry is { slot: BookingEventSlot; existing: CalendarEvent } => entry.existing !== null);
let status: BookingCalendarStatus;
if (live.length === 0) {
status = slots.some((s) => s.cancelled) ? (stale.length > 0 ? 'remove' : 'cancelled') : 'hidden';
} else {
const found = live.filter((entry) => entry.existing);
const changed = found.some((entry) => eventNeedsUpdate(entry.existing as CalendarEvent, entry.slot.event as Partial<CalendarEvent>));
if (found.length === 0 && stale.length === 0) status = 'add';
else if (found.length < live.length || changed || stale.length > 0) status = 'update';
else status = 'in-calendar';
}
return { status, live, stale };
}
/**
* Calendar state and actions for one booking card. Looks each of the
* booking's event uids up in the primary calendar account, merges what it
* finds into the calendar store, and never creates an event whose uid it
* found. Hidden unless the account has calendars and the booking isn't over.
*/
export function useBookingCalendar(
booking: StructuredCard | null | undefined,
email: Pick<Email, 'from' | 'subject'> | null | undefined,
options: { openCalendar?: () => void } = {},
): BookingCalendar {
const t = useLocaleStore((s) => s.t);
const hasCalendar = useHasCalendar();
const calendars = useCalendarStore((s) => s.calendars);
const storeEvents = useCalendarStore((s) => s.events);
const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds);
const createEvent = useCalendarStore((s) => s.createEvent);
const updateEvent = useCalendarStore((s) => s.updateEvent);
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const { openCalendar } = options;
const [lookup, setLookup] = useState<Lookup | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<BookingCalendar['notice']>(null);
const [refresh, setRefresh] = useState(0);
const senderDomain = senderDomainOf(email?.from?.[0]?.email);
const subject = email?.subject;
const labels = useMemo<Partial<BookingEventLabels>>(() => ({
reference: (reference) => t('structured_mail_calendar.description.reference', 'Booking reference: {reference}', { reference }),
passengers: (names) => t('structured_mail_calendar.description.passengers', 'Passengers: {names}', { names }),
seats: (seats) => t('structured_mail_calendar.description.seats', 'Seats: {seats}', { seats }),
checkin: (url) => t('structured_mail_calendar.description.checkin', 'Check in: {url}', { url }),
manage: (url) => t('structured_mail_calendar.description.manage', 'Manage booking: {url}', { url }),
ticket: (url) => t('structured_mail_calendar.description.ticket', 'Ticket: {url}', { url }),
phone: (phone) => t('structured_mail_calendar.description.phone', 'Phone: {phone}', { phone }),
car: (car) => t('structured_mail_calendar.description.car', 'Car: {car}', { car }),
tickets: (numbers) => t('structured_mail_calendar.description.tickets', 'Tickets: {numbers}', { numbers }),
partySize: (count) => t('structured_mail_calendar.description.party_size', 'Party size: {count}', { count }),
rentalCar: (company) => (company
? t('structured_mail_calendar.title.rental_car_company', 'Rental car: {company}', { company })
: t('structured_mail_calendar.title.rental_car', 'Rental car')),
addedFrom: (domain, mailSubject) => (mailSubject
? t('structured_mail_calendar.description.added_from_subject', 'Added from the mail “{subject}” from {domain}', { domain, subject: mailSubject })
: t('structured_mail_calendar.description.added_from', 'Added from a mail from {domain}', { domain })),
}), [t]);
const slots = useMemo(
() => (booking && senderDomain ? bookingEventSlots(booking, { senderDomain, mailSubjectOrProvider: subject, labels }) : []),
[booking, senderDomain, subject, labels],
);
const uidKey = slots.map((s) => s.uid).join(' ');
const endsAt = booking && booking.kind !== 'shipment' ? bookingEndsAt(booking) : null;
const over = endsAt === null || endsAt < Date.now();
const active = hasCalendar && !over && slots.length > 0;
useEffect(() => {
if (!active || !uidKey) return;
let current = true;
const generation = calendarStoreGeneration();
const uids = uidKey.split(' ');
// The primary calendar account, the only one the picker offers.
Promise.all(uids.map((uid) => findEventsByUid(uid)))
.then((results) => {
// An account switch since: what came back is the previous account's.
if (!current || generation !== calendarStoreGeneration()) return;
const events: Record<string, CalendarEvent | null> = {};
uids.forEach((uid, i) => {
events[uid] = results[i].find((e) => e.uid === uid && !e.recurrenceId) ?? null;
if (events[uid]) removedUids.delete(uid);
});
setLookup({ key: uidKey, failed: false, events });
const hits = Object.values(events).filter((e): e is CalendarEvent => e !== null);
if (hits.length > 0) {
useCalendarStore.setState((s) => {
const ids = new Set(s.events.map((e) => e.id));
const added = hits.filter((e) => !ids.has(e.id));
return added.length > 0 ? { events: [...s.events, ...added] } : s;
});
}
})
.catch(() => {
// Not knowing what the calendar holds, offering "Add" could duplicate: show nothing.
if (current) setLookup({ key: uidKey, failed: true });
});
return () => { current = false; };
}, [active, uidKey, refresh]);
const targets = useMemo(() => {
const isSubscriptionCalendar = (id: string) => subscriptions.some((s) => s.calendarId === id);
return bookingTargetCalendars(calendars, isSubscriptionCalendar, taskOnlyCalendarIds);
}, [calendars, subscriptions, taskOnlyCalendarIds]);
const known = lookup && lookup.key === uidKey && !lookup.failed ? lookup.events : null;
const existingOf = useCallback((uid: string): CalendarEvent | null => {
if (removedUids.has(uid)) return null;
const stored = known?.[uid];
// The store's copy of the same event is fresher after a change elsewhere.
if (stored) return storeEvents.find((e) => e.id === stored.id) ?? stored;
// Added since the lookup, maybe by another card for the same booking.
return storeEvents.find((e) => e.uid === uid && !e.recurrenceId && !e.isShared) ?? null;
}, [known, storeEvents]);
const plan = useMemo<Plan>(() => {
if (!active) return { status: 'hidden', live: [], stale: [] };
if (!lookup || lookup.key !== uidKey) return { status: 'loading', live: [], stale: [] };
if (lookup.failed) return { status: 'hidden', live: [], stale: [] };
return planFor(slots, existingOf);
}, [active, lookup, uidKey, slots, existingOf]);
const status: BookingCalendarStatus = plan.status === 'add' && targets.length === 0 ? 'hidden' : plan.status;
const remember = useCallback((uid: string, event: CalendarEvent | null) => {
setLookup((prev) => (prev && !prev.failed && prev.key === uidKey ? { ...prev, events: { ...prev.events, [uid]: event } } : prev));
}, [uidKey]);
/** Run one action; on failure say why and look the events up again. */
const run = useCallback(async (done: NonNullable<BookingCalendar['notice']>, work: (assertScope: () => void) => Promise<void>) => {
if (busy) return;
setBusy(true);
setError(null);
setNotice(null);
const scope = calendarOperationScope();
try {
await work(scope.assert);
setNotice(done);
} catch (err) {
// The account changed under the action: nothing failed in the account shown now.
if (err instanceof CalendarAccountChangedError) return;
setError(t('structured_mail_calendar.action_failed', "Couldn't change your calendar. Try again."));
setRefresh((n) => n + 1);
} finally {
setBusy(false);
}
}, [busy, t]);
const createMissing = useCallback(async (calendarId: string, assertScope: () => void): Promise<void> => {
for (const { slot, existing } of plan.live) {
if (existing) continue;
assertScope();
const created = await createEvent(sanitizeOutgoingCalendarEventData({ ...slot.event }), calendarId);
removedUids.delete(slot.uid);
remember(slot.uid, created);
}
}, [plan.live, createEvent, remember]);
const removeStale = useCallback(async (assertScope: () => void): Promise<void> => {
for (const { slot, existing } of plan.stale) {
assertScope();
// Marked before the store drops the event, so other cards that
// re-render on that change already see it gone.
removedUids.add(slot.uid);
try {
await deleteEvent(existing.id);
} catch (err) {
removedUids.delete(slot.uid);
throw err;
}
remember(slot.uid, null);
}
}, [plan.stale, deleteEvent, remember]);
const add = useCallback((calendarId?: string) => run('added', async (assertScope) => {
const target = calendarId ?? targets[0]?.id;
if (!target || !targets.some((c) => c.id === target)) throw new Error('no writable calendar');
await createMissing(target, assertScope);
}), [run, targets, createMissing]);
const update = useCallback(() => run('updated', async (assertScope) => {
for (const { slot, existing } of plan.live) {
if (!existing || !eventNeedsUpdate(existing, slot.event as Partial<CalendarEvent>)) continue;
// Only what moved; title and notes the user changed stay.
const patch = bookingEventPatch(existing, slot.event as Partial<CalendarEvent>, senderDomain);
if (Object.keys(patch).length === 0) continue;
assertScope();
await updateEvent(existing.id, patch);
// The store merged the patch into its copy, which the card reads first.
remember(slot.uid, { ...existing, ...patch } as CalendarEvent);
}
if (plan.live.some((entry) => !entry.existing)) {
// Missing legs go where the booking's other events are.
const used = plan.live.flatMap((entry) => Object.keys(entry.existing?.calendarIds ?? {}));
const target = targets.find((c) => used.includes(c.id))?.id ?? targets[0]?.id;
if (!target) throw new Error('no writable calendar');
await createMissing(target, assertScope);
}
await removeStale(assertScope);
}), [run, plan.live, senderDomain, targets, updateEvent, remember, createMissing, removeStale]);
const remove = useCallback(() => run('removed', removeStale), [run, removeStale]);
const viewInCalendar = useCallback(() => {
const target = plan.live.find((entry) => entry.existing)?.existing ?? plan.stale[0]?.existing;
if (target) {
const startMs = target.utcStart ? Date.parse(target.utcStart) : NaN;
setPendingCalendarOpen({
kind: 'event',
eventId: target.id,
serverId: target.originalId ?? target.id,
...(Number.isFinite(startMs) ? { startMs } : {}),
});
}
openCalendar?.();
}, [plan, openCalendar]);
return {
status,
calendars: targets,
busy,
error: status === 'hidden' ? null : error,
notice: status === 'hidden' ? null : notice,
add,
update,
remove,
viewInCalendar,
};
}