diff --git a/lib/structured-mail/__tests__/calendar.test.ts b/lib/structured-mail/__tests__/calendar.test.ts index 79c79f4cd..8fdab5bdb 100644 --- a/lib/structured-mail/__tests__/calendar.test.ts +++ b/lib/structured-mail/__tests__/calendar.test.ts @@ -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 | 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'); }); }); diff --git a/lib/structured-mail/calendar.ts b/lib/structured-mail/calendar.ts index 67cbd1f8c..3ae29cf11 100644 --- a/lib/structured-mail/calendar.ts +++ b/lib/structured-mail/calendar.ts @@ -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(); - 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, wanted: Partial): 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): Partial { + return normalizeCalendarEventLike(sanitizeOutgoingCalendarEventData({ ...event })); +} + +/** Start, duration, all-day flag or zone differ (the zone only matters for timed events). */ +function whenDiffers(have: Partial, want: Partial): 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, want: Partial): 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, wanted: Partial): Partial { - 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, wanted: Partial): 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, wanted: Partial, senderDomain?: string): Partial { + const have = normalizeCalendarEventLike({ ...existing }); + const want = asStoredEvent(wanted); + const patch: Partial = {}; + 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; }