fix: update moved bookings in place and keep user edits when updating calendar events

This commit is contained in:
Linus Rath
2026-10-30 05:39:01 +01:00
parent 521151b1f7
commit 87d9f89238
2 changed files with 276 additions and 98 deletions
+123 -15
View File
@@ -10,6 +10,7 @@ import {
fnv1a64,
organizationalDomain,
} from '../calendar';
import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard, type TransportBooking } from '..';
const ctx = 'http://schema.org';
@@ -61,14 +62,23 @@ const SFO_JFK = flight({
});
describe('organizationalDomain', () => {
it('keeps the last two labels, three under short second-level labels of country domains', () => {
it('keeps the last two labels, three under a listed two-label public suffix', () => {
expect(organizationalDomain('booking.flixbus.com')).toBe('flixbus.com');
expect(organizationalDomain('flixbus.com')).toBe('flixbus.com');
expect(organizationalDomain('mail.Lufthansa.COM.')).toBe('lufthansa.com');
expect(organizationalDomain('news.easyjet.co.uk')).toBe('easyjet.co.uk');
expect(organizationalDomain('shop.example.co.uk')).toBe('example.co.uk');
expect(organizationalDomain('mail.qantas.com.au')).toBe('qantas.com.au');
expect(organizationalDomain('info.bahn.de')).toBe('bahn.de');
});
it('treats short brand names under country domains as brands, not suffixes', () => {
expect(organizationalDomain('mail.klm.nl')).toBe('klm.nl');
expect(organizationalDomain('info.klm.nl')).toBe('klm.nl');
expect(organizationalDomain('news.dhl.de')).toBe('dhl.de');
// Not listed, so both collapse; the reference still has to match (see the list's comment).
expect(organizationalDomain('x.gouv.fr')).toBe(organizationalDomain('y.gouv.fr'));
});
});
describe('fnv1a64', () => {
@@ -369,14 +379,59 @@ describe('bookingEventUid', () => {
expect(bookingEventUid(day2, 'lufthansa.com')).not.toBe(bookingEventUid(day1, 'lufthansa.com'));
});
it('keeps two legs of one booking apart when they share flight and departure', async () => {
const [card] = (await cards([
lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']),
lh(['2026-12-08T10:15:00+01:00', '2026-12-08T13:05:00-05:00']),
])) as TransportBooking[];
expect(card.legs).toHaveLength(2);
const uids = bookingToEvents(card, { senderDomain: 'lufthansa.com' }).map((e) => e.uid);
expect(new Set(uids).size).toBe(2);
it('gives a leg the same uid whatever other legs the mail lists, and offers no event for legs that would share one', async () => {
const day1 = lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']);
const day8 = lh(['2026-12-08T10:15:00+01:00', '2026-12-08T13:05:00-05:00']);
const back = flight({ airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'] });
const [both] = (await cards([day1, day8, back])) as TransportBooking[];
const [one] = (await cards([day1, back])) as TransportBooking[];
expect(both.legs).toHaveLength(3);
expect(bookingEventUid(both, 'lufthansa.com', both.legs[0])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[0]));
expect(bookingEventUid(both, 'lufthansa.com', both.legs[2])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[1]));
// LH 400 FRA twice under one reference: one uid can't hold both, so neither is offered (nor removed); LH 401 still is.
const slots = bookingEventSlots(both, { senderDomain: 'lufthansa.com' });
expect(slots.map((slot) => [slot.event !== null, slot.cancelled])).toEqual([[false, false], [false, false], [true, false]]);
expect(bookingToEvents(both, { senderDomain: 'lufthansa.com' }).map((e) => e.title)).toEqual(['LH 401 JFK → FRA']);
});
it('ignores spaces in train numbers', async () => {
const train = (number: string) => ({
'@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG577',
reservationFor: {
'@type': 'TrainTrip', trainNumber: number, provider: { name: 'DB' },
departureStation: { name: 'Hannover Hbf', address: { addressCountry: 'DE' } }, departureTime: '2026-12-01T10:00:00+01:00',
arrivalStation: { name: 'Berlin Hbf', address: { addressCountry: 'DE' } }, arrivalTime: '2026-12-01T11:40:00+01:00',
},
});
const spaced = await booking(train('ICE 577'));
expect(bookingEventUid(spaced, 'bahn.de')).toBe(bookingEventUid(await booking(train('ICE577')), 'bahn.de'));
expect(bookingEventUid(spaced, 'bahn.de')).not.toBe(bookingEventUid(await booking(train('ICE 578')), 'bahn.de'));
});
it('keeps the uid of a rescheduled car, event or table that has a reference, and keys on the date without one', async () => {
const table = (ref: string | undefined, start: string) => ({
'@context': ctx, '@type': 'FoodEstablishmentReservation', ...(ref ? { reservationNumber: ref } : {}), partySize: 2,
reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { addressCountry: 'DE' } }, startTime: start,
});
const car = (ref: string | undefined, pickup: string, dropoff: string) => ({
'@context': ctx, '@type': 'RentalCarReservation', ...(ref ? { reservationNumber: ref } : {}),
reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } },
pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: pickup,
dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: dropoff,
});
const show = (ref: string | undefined, name: string, start: string) => ({
'@context': ctx, '@type': 'EventReservation', ...(ref ? { reservationNumber: ref } : {}),
reservationFor: { '@type': 'Event', name, startDate: start, location: { name: 'Werkstatt', address: { addressCountry: 'DE' } } },
});
const uid = async (item: unknown) => bookingEventUid(await booking(item), 'example-tickets.de');
expect(await uid(table('TR-1', '2026-12-05T19:30:00+01:00'))).toBe(await uid(table('TR-1', '2026-12-06T20:00:00+01:00')));
expect(await uid(table(undefined, '2026-12-05T19:30:00+01:00'))).not.toBe(await uid(table(undefined, '2026-12-06T19:30:00+01:00')));
expect(await uid(car('RC-1', '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).toBe(await uid(car('RC-1', '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00')));
expect(await uid(car(undefined, '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).not.toBe(await uid(car(undefined, '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00')));
expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).toBe(await uid(show('EV-1', 'Workshop', '2026-12-12T14:00:00+01:00')));
// One order, two events: the name keeps them apart.
expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show('EV-1', 'Concert', '2026-12-05T14:00:00+01:00')));
expect(await uid(show(undefined, 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show(undefined, 'Workshop', '2026-12-12T14:00:00+01:00')));
});
it('keys stays on reference and hotel, adding the check-in date only without reference', async () => {
@@ -415,23 +470,76 @@ describe('eventNeedsUpdate', () => {
expect(eventNeedsUpdate(serverShaped(wanted), wanted)).toBe(false);
});
it('sees a changed start, duration, zone, title or location zone', async () => {
it('sees a changed start, duration, zone or location', async () => {
const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER);
const stored = serverShaped(wanted);
expect(eventNeedsUpdate({ ...stored, start: '2026-12-04T21:15:00' }, wanted)).toBe(true);
expect(eventNeedsUpdate({ ...stored, duration: 'PT7H' }, wanted)).toBe(true);
expect(eventNeedsUpdate({ ...stored, timeZone: 'America/Denver' }, wanted)).toBe(true);
expect(eventNeedsUpdate({ ...stored, title: 'UA 110 SFO → EWR' }, wanted)).toBe(true);
expect(eventNeedsUpdate({ ...stored, locations: { ...stored.locations, arr: { ...stored.locations!.arr, timeZone: 'America/Chicago' } } }, wanted)).toBe(true);
expect(eventNeedsUpdate({ ...stored, locations: { dep: stored.locations!.dep } }, wanted)).toBe(true);
});
it('ignores the description and the user\'s own locations, and keeps those in the patch', async () => {
it('does not compare titles or descriptions: the user may rename the event or write notes', async () => {
const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER);
expect(eventNeedsUpdate({ ...serverShaped(wanted), title: 'Flight to New York', description: 'my notes' }, wanted)).toBe(false);
});
it('ignores the zone of two all-day events', async () => {
const stay = await booking({
'@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-3', reservationFor: { '@type': 'Hotel', name: 'Hotel' },
checkinDate: '2026-12-04', checkoutDate: '2026-12-06',
});
const [wanted] = bookingToEvents(stay, { senderDomain: 'hotel.test' });
expect(eventNeedsUpdate({ ...serverShaped(wanted), timeZone: 'Europe/Berlin' }, wanted)).toBe(false);
expect(eventNeedsUpdate({ ...serverShaped(wanted), duration: 'P2D' }, wanted)).toBe(true);
});
it('sees no change in a midnight-to-midnight span the store saved as all-day', async () => {
const card = await booking({
'@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-0000',
reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } },
pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: '2026-12-04T00:00:00+01:00',
dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: '2026-12-07T00:00:00+01:00',
});
const [wanted] = bookingToEvents(card, { senderDomain: 'sixt.de' });
expect(wanted).toMatchObject({ start: '2026-12-04T00:00:00', duration: 'PT72H', timeZone: 'Europe/Berlin' });
// What the store's createEvent sends, and what its lookup turns the server's answer into.
const sent = sanitizeOutgoingCalendarEventData({ ...wanted });
expect(sent).toMatchObject({ showWithoutTime: true, duration: 'P3D', timeZone: null });
const readBack = normalizeCalendarEventLike(serverShaped(sent));
expect(eventNeedsUpdate(readBack, wanted)).toBe(false);
expect(bookingEventPatch(readBack, wanted, 'sixt.de')).toEqual({});
});
it('patches only what moved, and keeps the user\'s title, notes and own locations', async () => {
const before = await booking(SFO_JFK);
const after = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, departureTime: '2026-12-04T21:00:00-08:00', arrivalTime: '2026-12-05T07:15:00-05:00' } });
const [old] = bookingToEvents(before, SENDER);
const [now] = bookingToEvents(after, SENDER);
const mine = { '@type': 'Location', name: 'Gate B12' } as CalendarEvent['locations'] extends Record<string, infer L> | null ? L : never;
const stored = { ...serverShaped(wanted), description: 'my notes', locations: { ...serverShaped(wanted).locations, mine } };
expect(eventNeedsUpdate(stored, wanted)).toBe(false);
expect(bookingEventPatch(stored, wanted).locations).toHaveProperty('mine');
const edited = { ...serverShaped(old), title: 'Flight to New York', description: `${old.description}\nmy notes`, locations: { ...serverShaped(old).locations, mine } };
const patch = bookingEventPatch(edited, now, 'united.com');
expect(patch).toEqual({ start: '2026-12-04T21:00:00', duration: 'PT7H15M', timeZone: 'America/Los_Angeles', showWithoutTime: false });
expect(patch).not.toHaveProperty('title');
expect(patch).not.toHaveProperty('description');
expect(patch).not.toHaveProperty('locations');
const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } });
const [toEwr] = bookingToEvents(diverted, SENDER);
const locationPatch = bookingEventPatch(edited, toEwr, 'united.com');
expect(locationPatch.locations).toMatchObject({ mine: { name: 'Gate B12' }, arr: { name: 'Newark Liberty International Airport (EWR)' } });
expect(locationPatch).not.toHaveProperty('title');
expect(locationPatch).not.toHaveProperty('start');
});
it('updates title and description while the description is still the generated one', async () => {
const [old] = bookingToEvents(await booking(SFO_JFK), SENDER);
const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } });
const [toEwr] = bookingToEvents(diverted, { ...SENDER, mailSubjectOrProvider: 'Change to your flight' });
const patch = bookingEventPatch(serverShaped(old), toEwr, 'united.com');
expect(patch).toMatchObject({ title: 'UA 110 SFO → EWR', description: toEwr.description });
expect(bookingEventPatch(serverShaped(old), toEwr)).not.toHaveProperty('title');
});
});
+153 -83
View File
@@ -13,6 +13,7 @@
* domain never can.
*/
import type { CalendarEvent, CalendarLocation } from '@/lib/jmap/types';
import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { fixedOffsetZone, wallClockAt } from './time-zones';
import type {
Address,
@@ -31,16 +32,28 @@ const UID_DOMAIN = 'structured.bulwark';
// uid
/**
* The organisational domain of a sender domain: its last two labels, or three
* when the second-level label has at most three characters under a
* two-letter TLD (`co.uk`, `com.au`). `booking.flixbus.com` → `flixbus.com`.
* Public suffixes of two labels that commercial senders sit under. Not the
* whole Public Suffix List: a suffix missing here (`gouv.fr`) makes every
* domain under it share one organisational domain. That is acceptable for
* uids, which also need the same booking reference (or, without one, the
* same leg and date) to match, and booking mail from such domains is rare.
*/
const TWO_LABEL_SUFFIXES = new Set([
'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk',
'com.au', 'net.au', 'org.au',
'co.jp', 'ne.jp', 'or.jp',
'com.br', 'co.nz', 'co.za', 'com.mx', 'com.tr', 'co.in', 'com.cn', 'com.sg', 'com.hk', 'co.kr', 'co.il',
'com.ar', 'com.tw', 'co.th', 'co.id', 'com.my', 'com.ph', 'co.at', 'or.at', 'com.es', 'com.pl', 'com.pt',
]);
/**
* The organisational domain of a sender domain: the last two labels, or three
* under a two-label public suffix (`co.uk`, `com.au`).
* `booking.flixbus.com` → `flixbus.com`, `shop.example.co.uk` → `example.co.uk`.
*/
export function organizationalDomain(fromDomain: string): string {
const labels = fromDomain.trim().toLowerCase().replace(/\.+$/, '').split('.').filter(Boolean);
if (labels.length <= 2) return labels.join('.');
const tld = labels[labels.length - 1];
const second = labels[labels.length - 2];
const count = tld.length === 2 && second.length <= 3 ? 3 : 2;
const count = labels.length >= 3 && TWO_LABEL_SUFFIXES.has(labels.slice(-2).join('.')) ? 3 : 2;
return labels.slice(-count).join('.');
}
@@ -94,19 +107,34 @@ function uidFrom(kind: Booking['kind'], parts: (string | undefined)[]): string {
}
/**
* The identity of a leg: carrier IATA code (the name only without one),
* number and departure code or name, after the reference. Without a
* reference the departure's local date is added. Never the time, so a
* schedule change keeps the uid.
* A leg's number as an identity: no spaces ("ICE 577" = "ICE577") and no
* leading zeros ("LX0072" in the markup is flight 72 on the boarding pass;
* legKey in normalize.ts strips them the same way).
*/
function legParts(booking: TransportBooking, leg: TransportLeg, withDate: boolean): (string | undefined)[] {
function legNumber(number: string | undefined): string {
return norm(number).replace(/\s+/g, '').replace(/^0+(?=\d)/, '');
}
/**
* The identity of a leg, from the leg alone: reference, carrier IATA code
* (the name only without one), number and departure code or name. Without a
* reference the departure's local date is added. Never the time, and with a
* reference not the date either, so a schedule change (even one that moves
* the departure past midnight) updates the same event.
*
* It never depends on the booking's other legs, so a later mail that lists
* only some of them finds the same events. The price: two legs of one
* booking with the same reference, flight and departure (the same commuter
* flight on two days) share a uid, and since one uid holds one event,
* bookingEventSlots offers neither of them.
*/
function legParts(booking: TransportBooking, leg: TransportLeg): (string | undefined)[] {
return [
booking.reference,
leg.carrierCode ?? leg.carrier,
// "LX0072" in the markup is flight 72 on the boarding pass (legKey does the same).
norm(leg.number).replace(/^0+(?=\d)/, ''),
legNumber(leg.number),
leg.departure.place.code ?? leg.departure.place.name,
...(withDate || !booking.reference ? [leg.departure.at.date] : []),
...(booking.reference ? [] : [leg.departure.at.date]),
];
}
@@ -117,30 +145,20 @@ function legParts(booking: TransportBooking, leg: TransportLeg, withDate: boolea
* starts with the sender's organisational domain.
*/
export function bookingEventUid(booking: Booking, senderDomain: string, leg?: TransportLeg): string {
return bookingUids(booking, senderDomain, leg ? [leg] : undefined)[0];
}
function bookingUids(booking: Booking, senderDomain: string, onlyLegs?: TransportLeg[]): string[] {
const domain = organizationalDomain(senderDomainOf(senderDomain));
// With a reference the date stays out, so a moved booking keeps its event;
// without one the date is part of what identifies it.
const dateUnlessReference = (date: string) => (booking.reference ? [] : [date]);
switch (booking.kind) {
case 'flight': case 'train': case 'bus': {
// Two legs of one booking with the same carrier, number and departure
// (the same commuter flight on two days) would share a uid: those get
// their date as well.
const keyOf = (leg: TransportLeg) => legParts(booking, leg, false).map(norm).join('|');
const counts = new Map<string, number>();
for (const leg of booking.legs) counts.set(keyOf(leg), (counts.get(keyOf(leg)) ?? 0) + 1);
return (onlyLegs ?? booking.legs).map((leg) => {
const clash = (counts.get(keyOf(leg)) ?? 0) > 1;
return uidFrom(booking.kind, [domain, ...legParts(booking, leg, clash)]);
});
}
case 'flight': case 'train': case 'bus':
return uidFrom(booking.kind, [domain, ...legParts(booking, leg ?? booking.legs[0])]);
case 'lodging':
return [uidFrom('lodging', [domain, booking.reference, booking.name, ...(booking.reference ? [] : [booking.checkin.date])])];
return uidFrom('lodging', [domain, booking.reference, booking.name, ...dateUnlessReference(booking.checkin.date)]);
case 'rentalcar':
return [uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, booking.pickup.at.date])];
return uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, ...dateUnlessReference(booking.pickup.at.date)]);
case 'event': case 'restaurant':
return [uidFrom(booking.kind, [domain, booking.reference, booking.name, booking.start.date])];
// One order can cover several events: the name stays in the key.
return uidFrom(booking.kind, [domain, booking.reference, booking.name, ...dateUnlessReference(booking.start.date)]);
}
}
@@ -439,15 +457,20 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
switch (booking.kind) {
case 'flight': case 'train': case 'bus': {
const uids = bookingUids(booking, options.senderDomain);
return booking.legs.map((leg, i) => ({
uid: uids[i],
cancelled: cancelled || leg.status === 'cancelled',
event: legEvent(booking, leg, uids[i], tail, labels),
}));
const uids = booking.legs.map((leg) => bookingEventUid(booking, options.senderDomain, leg));
return booking.legs.map((leg, i) => {
// Legs sharing a uid can't each have their own event: offer nothing
// for them, neither adding nor removing.
const shared = uids.indexOf(uids[i]) !== uids.lastIndexOf(uids[i]);
return {
uid: uids[i],
cancelled: !shared && (cancelled || leg.status === 'cancelled'),
event: shared ? null : legEvent(booking, leg, uids[i], tail, labels),
};
});
}
case 'lodging': {
const [uid] = bookingUids(booking, options.senderDomain);
const uid = bookingEventUid(booking, options.senderDomain);
const hotel = joinParts([booking.name, ...addressLines(booking.address)]);
const description = [
reference,
@@ -472,7 +495,7 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
}];
}
case 'rentalcar': {
const [uid] = bookingUids(booking, options.senderDomain);
const uid = bookingEventUid(booking, options.senderDomain);
const span = timedSpan(booking.pickup.at, booking.dropoff.at);
const locations: Locations = {};
if (span) {
@@ -494,7 +517,7 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
}];
}
case 'event': {
const [uid] = bookingUids(booking, options.senderDomain);
const uid = bookingEventUid(booking, options.senderDomain);
const description = [
reference,
booking.tickets.length > 0 ? labels.tickets(booking.tickets.join(', ')) : '',
@@ -517,7 +540,7 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
return [{ uid, cancelled, event: made }];
}
case 'restaurant': {
const [uid] = bookingUids(booking, options.senderDomain);
const uid = bookingEventUid(booking, options.senderDomain);
const span = timedSpan(booking.start, undefined);
const place = joinParts([booking.name, ...addressLines(booking.address)]);
return [{
@@ -578,49 +601,96 @@ const localDateTime = (value: string | undefined | null) => {
const OWN_LOCATION_IDS = ['dep', 'arr', 'hotel', 'pickup', 'dropoff', 'venue', 'restaurant'];
/**
* Whether the stored event differs from the one the booking now gives, in
* what matters for where it sits: start, duration, time zone, title, all-day
* flag and the name, zone and anchor of each location this module writes.
* Field by field, because the server adds defaults (`iCalendar`, nulls).
* An event as the calendar store saves it and reads it back: a timed event
* from midnight that lasts whole days becomes all-day without a zone
* (sanitizeOutgoingCalendarEventData on the way out, normalizeCalendarEventLike
* on the way in). Comparing against this keeps such an event from looking
* changed for ever.
*/
export function eventNeedsUpdate(existing: Partial<CalendarEvent>, wanted: Partial<CalendarEvent>): boolean {
if (localDateTime(existing.start) !== localDateTime(wanted.start)) return true;
if (durationParts(existing.duration) !== durationParts(wanted.duration ?? 'PT0S')) return true;
if ((existing.timeZone ?? null) !== (wanted.timeZone ?? null)) return true;
if ((existing.title ?? '') !== (wanted.title ?? '')) return true;
if (Boolean(existing.showWithoutTime) !== Boolean(wanted.showWithoutTime)) return true;
const have = existing.locations ?? {};
const want = wanted.locations ?? {};
for (const id of OWN_LOCATION_IDS) {
const a = have[id];
const b = want[id];
if (!a && !b) continue;
if (!a || !b) return true;
if ((a.name ?? '') !== (b.name ?? '')) return true;
if ((a.timeZone ?? null) !== (b.timeZone ?? null)) return true;
if ((a.relativeTo ?? null) !== (b.relativeTo ?? null)) return true;
}
return false;
export function asStoredEvent(event: Partial<CalendarEvent>): Partial<CalendarEvent> {
return normalizeCalendarEventLike(sanitizeOutgoingCalendarEventData({ ...event }));
}
/** Start, duration, all-day flag or zone differ (the zone only matters for timed events). */
function whenDiffers(have: Partial<CalendarEvent>, want: Partial<CalendarEvent>): boolean {
if (localDateTime(have.start) !== localDateTime(want.start)) return true;
if (durationParts(have.duration ?? 'PT0S') !== durationParts(want.duration ?? 'PT0S')) return true;
if (Boolean(have.showWithoutTime) !== Boolean(want.showWithoutTime)) return true;
if (want.showWithoutTime) return false;
return (have.timeZone ?? null) !== (want.timeZone ?? null);
}
/** A location this module writes is missing, extra, or has another name, zone or anchor. */
function locationsDiffer(have: Partial<CalendarEvent>, want: Partial<CalendarEvent>): boolean {
const a = have.locations ?? {};
const b = want.locations ?? {};
return OWN_LOCATION_IDS.some((id) => {
const x = a[id];
const y = b[id];
if (!x && !y) return false;
if (!x || !y) return true;
return (x.name ?? '') !== (y.name ?? '')
|| (x.timeZone ?? null) !== (y.timeZone ?? null)
|| (x.relativeTo ?? null) !== (y.relativeTo ?? null);
});
}
/**
* The patch that brings a stored event to the booking's current state. The
* user's own locations stay; ours are replaced.
* Whether the stored event sits somewhere else than the booking now says:
* start, duration, all-day flag, zone (timed events only) and the name, zone
* and anchor of each location this module writes. Field by field, because
* the server adds defaults (`iCalendar`, nulls). Title and description are
* not compared: the user may have renamed the event or written notes, and a
* title in another language is no reason to offer "Update".
*/
export function bookingEventPatch(existing: Partial<CalendarEvent>, wanted: Partial<CalendarEvent>): Partial<CalendarEvent> {
const locations: Locations = {};
for (const [id, loc] of Object.entries(existing.locations ?? {})) {
if (!OWN_LOCATION_IDS.includes(id)) locations[id] = loc;
export function eventNeedsUpdate(existing: Partial<CalendarEvent>, wanted: Partial<CalendarEvent>): boolean {
const have = normalizeCalendarEventLike({ ...existing });
const want = asStoredEvent(wanted);
return whenDiffers(have, want) || locationsDiffer(have, want);
}
/**
* Whether a stored description is still the one this module wrote: its last
* line names the sender's organisational domain ("Added from a mail from
* lufthansa.com"). Notes the user added below it make it theirs.
*/
function isOwnDescription(description: string | null | undefined, senderDomain: string | undefined): boolean {
if (!description || !senderDomain) return false;
const domain = organizationalDomain(senderDomainOf(senderDomain));
const lines = description.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return domain !== '' && (lines[lines.length - 1] ?? '').includes(domain);
}
/**
* The patch that brings a stored event to the booking's current state,
* touching only what differs: the times as one group (start, duration, zone,
* all-day flag, so the store can't half-convert them), and the locations this
* module writes (the user's own locations stay). Title and description change
* only while the description is still the generated one (see
* isOwnDescription), so user edits survive. `senderDomain` is the mail's From
* domain; without it title and description are never touched.
*/
export function bookingEventPatch(existing: Partial<CalendarEvent>, wanted: Partial<CalendarEvent>, senderDomain?: string): Partial<CalendarEvent> {
const have = normalizeCalendarEventLike({ ...existing });
const want = asStoredEvent(wanted);
const patch: Partial<CalendarEvent> = {};
if (whenDiffers(have, want)) {
patch.start = want.start;
patch.duration = want.duration;
patch.timeZone = want.timeZone ?? null;
patch.showWithoutTime = Boolean(want.showWithoutTime);
}
Object.assign(locations, wanted.locations ?? {});
return {
title: wanted.title,
start: wanted.start,
timeZone: wanted.timeZone ?? null,
duration: wanted.duration,
showWithoutTime: Boolean(wanted.showWithoutTime),
locations: Object.keys(locations).length > 0 ? locations : null,
...(wanted.description !== undefined ? { description: wanted.description } : {}),
...(wanted.freeBusyStatus ? { freeBusyStatus: wanted.freeBusyStatus } : {}),
};
if (locationsDiffer(have, want)) {
const locations: Locations = {};
for (const [id, loc] of Object.entries(existing.locations ?? {})) {
if (!OWN_LOCATION_IDS.includes(id)) locations[id] = loc;
}
Object.assign(locations, want.locations ?? {});
patch.locations = Object.keys(locations).length > 0 ? locations : null;
}
if (isOwnDescription(existing.description, senderDomain)) {
if (want.title !== undefined && want.title !== existing.title) patch.title = want.title;
if (want.description !== undefined && want.description !== existing.description) patch.description = want.description;
}
return patch;
}