From be87454eb7340a227c62afc157659a65085a1341 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:58:03 +0100 Subject: [PATCH 1/6] feat: turn booking cards into calendar events with stable uids --- .../__tests__/calendar.test.ts | 437 ++++++++++++ lib/structured-mail/calendar.ts | 625 ++++++++++++++++++ 2 files changed, 1062 insertions(+) create mode 100644 lib/structured-mail/__tests__/calendar.test.ts create mode 100644 lib/structured-mail/calendar.ts diff --git a/lib/structured-mail/__tests__/calendar.test.ts b/lib/structured-mail/__tests__/calendar.test.ts new file mode 100644 index 000000000..3126aea3e --- /dev/null +++ b/lib/structured-mail/__tests__/calendar.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { + bookingEndsAt, + bookingEventPatch, + bookingEventSlots, + bookingEventUid, + bookingToEvents, + eventNeedsUpdate, + fnv1a64, + organizationalDomain, +} from '../calendar'; +import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard, type TransportBooking } from '..'; + +const ctx = 'http://schema.org'; + +/** Cards as the reader gets them: parsed, validated, merged and zone-resolved. */ +async function cards(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise { + const html = `${items.map((item) => ``).join('')}`; + return resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones()); +} + +async function booking(item: unknown, mailDate?: string): Promise { + const found = await cards([item], mailDate); + expect(found).toHaveLength(1); + return found[0] as Booking; +} + +interface FlightSpec { + ref?: string; + airline?: { name?: string; iataCode?: string }; + number?: string; + dep: [name: string, code: string, time: string]; + arr: [name: string, code: string, time: string]; + status?: string; + passenger?: string; + seat?: string; +} + +function flight(spec: FlightSpec) { + return { + '@context': ctx, '@type': 'FlightReservation', + ...(spec.ref !== undefined ? { reservationNumber: spec.ref } : { reservationNumber: 'KT4M9Z' }), + underName: { '@type': 'Person', name: spec.passenger ?? 'Erika Mustermann' }, + ...(spec.seat ? { airplaneSeat: spec.seat } : {}), + ...(spec.status ? { reservationStatus: `http://schema.org/${spec.status}` } : {}), + checkinUrl: 'https://checkin.example-air.test/KT4M9Z', + reservationFor: { + '@type': 'Flight', flightNumber: spec.number ?? '110', airline: spec.airline ?? { name: 'United', iataCode: 'UA' }, + departureAirport: { '@type': 'Airport', name: spec.dep[0], iataCode: spec.dep[1] }, departureTime: spec.dep[2], + arrivalAirport: { '@type': 'Airport', name: spec.arr[0], iataCode: spec.arr[1] }, arrivalTime: spec.arr[2], + }, + }; +} + +const SENDER = { senderDomain: 'united.com' }; +const SFO_JFK = flight({ + dep: ['San Francisco International Airport', 'SFO', '2026-12-04T20:15:00-08:00'], + arr: ['John F. Kennedy International Airport', 'JFK', '2026-12-05T06:30:00-05:00'], + seat: '9A', +}); + +describe('organizationalDomain', () => { + it('keeps the last two labels, three under short second-level labels of country domains', () => { + 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('mail.qantas.com.au')).toBe('qantas.com.au'); + expect(organizationalDomain('info.bahn.de')).toBe('bahn.de'); + }); +}); + +describe('fnv1a64', () => { + it('matches the published FNV-1a 64-bit test vectors', () => { + expect(fnv1a64('')).toBe('cbf29ce484222325'); + expect(fnv1a64('a')).toBe('af63dc4c8601ec8c'); + expect(fnv1a64('foobar')).toBe('85944171f73967e8'); + }); +}); + +describe('bookingToEvents: flights', () => { + it('maps SFO 20:15 −08:00 → JFK 06:30 −05:00 to a Los Angeles start and an exact duration', async () => { + const card = await booking(SFO_JFK); + const [event] = bookingToEvents(card, SENDER); + expect(event).toMatchObject({ + '@type': 'Event', + title: 'UA 110 SFO → JFK', + start: '2026-12-04T20:15:00', + timeZone: 'America/Los_Angeles', + duration: 'PT7H15M', + showWithoutTime: false, + locations: { + dep: { '@type': 'Location', name: 'San Francisco International Airport (SFO)', timeZone: 'America/Los_Angeles', relativeTo: 'start' }, + arr: { '@type': 'Location', name: 'John F. Kennedy International Airport (JFK)', timeZone: 'America/New_York', relativeTo: 'end' }, + }, + }); + expect(event.uid).toMatch(/^bulwark-sm-flight-[0-9a-f]{16}@structured\.bulwark$/); + expect(event.description).toBe([ + 'Booking reference: KT4M9Z', + 'Passengers: Erika Mustermann (9A)', + 'Check in: https://checkin.example-air.test/KT4M9Z', + 'Added from a mail from united.com', + ].join('\n')); + expect(event).not.toHaveProperty('participants'); + expect(event).not.toHaveProperty('calendarIds'); + }); + + it.each([ + // Westbound over the date line: the local arrival date is a day later. + ['HNL → NRT', ['Honolulu', 'HNL', '2026-12-04T11:00:00-10:00'], ['Tokyo Narita', 'NRT', '2026-12-05T15:30:00+09:00'], '2026-12-04T11:00:00', 'Pacific/Honolulu', 'Asia/Tokyo', 'PT9H30M'], + ['LAX → SYD', ['Los Angeles', 'LAX', '2026-12-04T22:30:00-08:00'], ['Sydney', 'SYD', '2026-12-06T08:45:00+11:00'], '2026-12-04T22:30:00', 'America/Los_Angeles', 'Australia/Sydney', 'PT15H15M'], + // Eastbound: the local arrival time is earlier than the departure. + ['NRT → HNL', ['Tokyo Narita', 'NRT', '2026-12-04T21:00:00+09:00'], ['Honolulu', 'HNL', '2026-12-04T09:15:00-10:00'], '2026-12-04T21:00:00', 'Asia/Tokyo', 'Pacific/Honolulu', 'PT7H15M'], + ['SYD → LAX', ['Sydney', 'SYD', '2026-12-04T09:50:00+11:00'], ['Los Angeles', 'LAX', '2026-12-04T06:20:00-08:00'], '2026-12-04T09:50:00', 'Australia/Sydney', 'America/Los_Angeles', 'PT15H30M'], + ] as const)('crosses the date line %s', async (_name, dep, arr, start, depZone, arrZone, duration) => { + const card = await booking(flight({ dep: [...dep], arr: [...arr] })); + const [event] = bookingToEvents(card, SENDER); + expect(event).toMatchObject({ + start, timeZone: depZone, duration, + locations: { dep: { timeZone: depZone, relativeTo: 'start' }, arr: { timeZone: arrZone, relativeTo: 'end' } }, + }); + }); + + it('counts the real hours of a flight that lands after a DST change', async () => { + // New York moves to EDT at 02:00 on 14 March 2027; 23:00 GMT → 03:30 EDT is 8 h 30. + const card = await booking(flight({ + airline: { name: 'British Airways', iataCode: 'BA' }, number: '117', + dep: ['London Heathrow', 'LHR', '2027-03-13T23:00:00+00:00'], + arr: ['New York JFK', 'JFK', '2027-03-14T03:30:00-04:00'], + }), '2027-03-01T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'ba.com' }); + expect(event).toMatchObject({ start: '2027-03-13T23:00:00', timeZone: 'Europe/London', duration: 'PT8H30M' }); + }); + + it('adds no event for a cancelled leg but still names its uid for removal', async () => { + const outbound = flight({ dep: ['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], arr: ['New York JFK', 'JFK', '2026-12-01T13:05:00-05:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M' }); + const back = { ...flight({ dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', status: 'ReservationCancelled' }) }; + const [card] = (await cards([outbound, back])) as TransportBooking[]; + expect(card.legs).toHaveLength(2); + const events = bookingToEvents(card, { senderDomain: 'lufthansa.com' }); + expect(events.map((e) => e.title)).toEqual(['LH 400 FRA → JFK']); + const slots = bookingEventSlots(card, { senderDomain: 'lufthansa.com' }); + expect(slots.map((s) => s.cancelled)).toEqual([false, true]); + expect(slots[1].uid).toBe(bookingEventUid(card, 'lufthansa.com', card.legs[1])); + }); + + it('gives a pass leg without arrival a zero-length event', async () => { + const pass: TransportBooking = { + key: 'flight|ref:pq1', kind: 'flight', reference: 'PQ1RST', passengers: ['Erika Mustermann'], links: {}, source: 'pkpass', + legs: [{ + carrierCode: 'LH', number: '400', seats: [{ passenger: 'Erika Mustermann', seat: '31C' }], + departure: { place: { code: 'FRA', name: 'Frankfurt' }, at: { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z' } }, + }], + }; + const [resolved] = resolveCards([pass], await loadAirportZones()) as TransportBooking[]; + const [event] = bookingToEvents(resolved, { senderDomain: 'lufthansa.com' }); + expect(event).toMatchObject({ start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT0S', title: 'LH 400 FRA' }); + expect(event.locations).not.toHaveProperty('arr'); + }); +}); + +describe('bookingToEvents: fixed offsets', () => { + const bus = (dep: string, arr: string) => ({ + '@context': ctx, '@type': 'BusReservation', reservationNumber: 'BX20471', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'BusTrip', busNumber: 'N12', busCompany: { name: 'Coastline Coaches' }, + departureBusStop: { '@type': 'BusStation', name: 'Harbour Terminal' }, departureTime: dep, + arrivalBusStop: { '@type': 'BusStation', name: 'Airport Stop 3' }, arrivalTime: arr, + }, + }); + + it('uses Etc/GMT+8 for a −08:00 time without a known zone, keeping the wall clock', async () => { + const card = await booking(bus('2026-12-04T20:15:00-08:00', '2026-12-04T22:45:00-08:00')); + const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' }); + expect(event).toMatchObject({ + title: 'N12 Harbour Terminal → Airport Stop 3', + start: '2026-12-04T20:15:00', timeZone: 'Etc/GMT+8', duration: 'PT2H30M', + locations: { dep: { timeZone: 'Etc/GMT+8', relativeTo: 'start' }, arr: { timeZone: 'Etc/GMT+8', relativeTo: 'end' } }, + }); + }); + + it('falls back to the UTC instant in Etc/UTC for a half-hour offset', async () => { + const card = await booking(bus('2026-12-04T10:00:00+05:30', '2026-12-04T13:15:00+05:30')); + const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' }); + expect(event).toMatchObject({ start: '2026-12-04T04:30:00', timeZone: 'Etc/UTC', duration: 'PT3H15M' }); + }); +}); + +describe('bookingToEvents: trains, stays, cars, events, tables', () => { + it('names stations with their addresses and counts the extra hour of a DST change', async () => { + // Berlin falls back from CEST to CET at 03:00 on 25 October 2026: 22:00 → 08:00 is 11 hours. + const card = await booking({ + '@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG4711', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'TrainTrip', trainNumber: 'NJ 40421', provider: { name: 'Nachtzug' }, + departureStation: { '@type': 'TrainStation', name: 'München Hbf', address: { streetAddress: 'Bayerstraße 10A', postalCode: '80335', addressLocality: 'München', addressCountry: 'DE' } }, + departureTime: '2026-10-24T22:00:00+02:00', + arrivalStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { streetAddress: 'Hachmannplatz 16', postalCode: '20099', addressLocality: 'Hamburg', addressCountry: 'DE' } }, + arrivalTime: '2026-10-25T08:00:00+01:00', + }, + }, '2026-10-20T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'nachtzug.test' }); + expect(event).toMatchObject({ + title: 'NJ 40421 München Hbf → Hamburg Hbf', + start: '2026-10-24T22:00:00', timeZone: 'Europe/Berlin', duration: 'PT11H', + locations: { + dep: { name: 'München Hbf, Bayerstraße 10A, 80335 München', timeZone: 'Europe/Berlin', relativeTo: 'start' }, + arr: { name: 'Hamburg Hbf, Hachmannplatz 16, 20099 Hamburg', timeZone: 'Europe/Berlin', relativeTo: 'end' }, + }, + }); + }); + + const hotel = (checkin: string, checkout: string) => ({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-88213', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'Hotel', name: 'Hotel Adlon Kempinski', telephone: '+49 30 22610', + address: { streetAddress: 'Unter den Linden 77', postalCode: '10117', addressLocality: 'Berlin', addressCountry: 'DE' }, + }, + checkinDate: checkin, checkoutDate: checkout, + }); + + it('makes a stay with dates only an all-day event that includes the check-out day', async () => { + const card = await booking(hotel('2026-12-04', '2026-12-06')); + const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' }); + expect(event).toMatchObject({ + title: 'Hotel Adlon Kempinski', start: '2026-12-04T00:00:00', showWithoutTime: true, timeZone: null, + duration: 'P3D', freeBusyStatus: 'free', + locations: { hotel: { name: 'Hotel Adlon Kempinski, Unter den Linden 77, 10117 Berlin' } }, + }); + expect(event.locations?.hotel).not.toHaveProperty('timeZone'); + expect(event.description).toContain('Phone: +49 30 22610'); + }); + + it('makes a stay with times a timed, free event in the hotel zone', async () => { + const card = await booking(hotel('2026-12-04T15:00:00+01:00', '2026-12-06T11:00:00+01:00')); + const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' }); + expect(event).toMatchObject({ + start: '2026-12-04T15:00:00', timeZone: 'Europe/Berlin', duration: 'PT44H', showWithoutTime: false, freeBusyStatus: 'free', + locations: { hotel: { timeZone: 'Europe/Berlin' } }, + }); + }); + + it('maps a rental car from pickup to drop-off, marked free', async () => { + const card = await booking({ + '@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-55120', underName: 'Erika Mustermann', + reservationFor: { '@type': 'RentalCar', name: 'Golf', brand: { name: 'VW' }, rentalCompany: { name: 'Sixt' } }, + pickupLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } }, + pickupTime: '2026-12-04T09:00:00+01:00', + dropoffLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } }, + dropoffTime: '2026-12-07T18:00:00+01:00', + }); + const [event] = bookingToEvents(card, { senderDomain: 'sixt.de' }); + expect(event).toMatchObject({ + title: 'Rental car: Sixt', start: '2026-12-04T09:00:00', timeZone: 'Europe/Berlin', duration: 'PT81H', freeBusyStatus: 'free', + locations: { pickup: { relativeTo: 'start', timeZone: 'Europe/Berlin' }, dropoff: { relativeTo: 'end', timeZone: 'Europe/Berlin' } }, + }); + expect(event.description).toContain('Car: VW Golf'); + }); + + const concert = (start: string, end?: string, address: Record = { addressLocality: 'Berlin', addressCountry: 'DE' }) => ({ + '@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV-30017', underName: 'Erika Mustermann', + reservedTicket: { '@type': 'Ticket', ticketNumber: 'T-9001' }, + reservationFor: { + '@type': 'Event', name: 'Grafikdesign: Illustrator-Workshop', startDate: start, ...(end ? { endDate: end } : {}), + location: { '@type': 'Place', name: 'Werkstatt Mitte', address }, + }, + }); + + it('gives an event without an end an explicit zero duration', async () => { + const card = await booking(concert('2026-07-18T14:00:00+02:00'), '2026-06-01T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'workshops.test' }); + expect(event).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: 'Europe/Berlin', duration: 'PT0S', locations: { venue: { name: 'Werkstatt Mitte, Berlin' } } }); + expect(event.description).toContain('Tickets: T-9001'); + }); + + it('runs an event to its end, to the end of the day for a date-only end, and keeps floating times floating', async () => { + const timed = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18T19:00:00+02:00'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(timed, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT5H', timeZone: 'Europe/Berlin' }); + const toDayEnd = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(toDayEnd, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT9H59M59S' }); + const floating = await booking(concert('2026-07-18T14:00:00', '2026-07-18T16:30:00', { addressLocality: 'Somewhere' }), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(floating, { senderDomain: 'workshops.test' })[0]).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: null, duration: 'PT2H30M' }); + }); + + it('makes a date-only event all-day over its days', async () => { + const card = await booking(concert('2026-07-18', '2026-07-19'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(card, { senderDomain: 'workshops.test' })[0]).toMatchObject({ + start: '2026-07-18T00:00:00', showWithoutTime: true, timeZone: null, duration: 'P2D', + }); + }); + + it('books a table as a zero-length event with the party size', async () => { + const card = await booking({ + '@context': ctx, '@type': 'FoodEstablishmentReservation', reservationNumber: 'TR-2207', underName: 'Erika Mustermann', partySize: 2, + reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { streetAddress: 'Rudi-Dutschke-Straße 26', postalCode: '10969', addressLocality: 'Berlin', addressCountry: 'DE' } }, + startTime: '2026-12-05T19:30:00+01:00', + }); + const [event] = bookingToEvents(card, { senderDomain: 'tim-raue.de' }); + expect(event).toMatchObject({ + title: 'Restaurant Tim Raue', start: '2026-12-05T19:30:00', timeZone: 'Europe/Berlin', duration: 'PT0S', + locations: { restaurant: { name: 'Restaurant Tim Raue, Rudi-Dutschke-Straße 26, 10969 Berlin', timeZone: 'Europe/Berlin' } }, + }); + expect(event.description).toBe('Booking reference: TR-2207\nParty size: 2\nAdded from a mail from tim-raue.de'); + }); + + it('uses the labels it is given and names the mail subject', async () => { + const card = await booking(SFO_JFK); + const [event] = bookingToEvents(card, { + senderDomain: 'mail.united.com', + mailSubjectOrProvider: 'Your trip to New York', + labels: { reference: (r) => `Buchungscode: ${r}` }, + }); + expect(event.description).toContain('Buchungscode: KT4M9Z'); + expect(event.description).toContain('Added from the mail "Your trip to New York" from united.com'); + }); +}); + +describe('bookingEventUid', () => { + const lh = (time: [string, string], extra: Partial = {}) => flight({ + airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M', + dep: ['Frankfurt', 'FRA', time[0]], arr: ['New York JFK', 'JFK', time[1]], ...extra, + }); + + it('stays the same when the schedule changes, and the event then needs an update', async () => { + const before = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + const after = await booking(lh(['2026-12-01T12:40:00+01:00', '2026-12-01T15:35:00-05:00'])); + const [old] = bookingToEvents(before, { senderDomain: 'lufthansa.com' }); + const [now] = bookingToEvents(after, { senderDomain: 'lufthansa.com' }); + expect(now.uid).toBe(old.uid); + expect(eventNeedsUpdate(old as CalendarEvent, now)).toBe(true); + }); + + it('differs across sender domains and is shared by the subdomains of one', async () => { + const card = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + expect(bookingEventUid(card, 'lufthansa.com')).not.toBe(bookingEventUid(card, 'lufthansa-deals.com')); + expect(bookingEventUid(card, 'booking.flixbus.com')).toBe(bookingEventUid(card, 'flixbus.com')); + expect(bookingEventUid(card, 'noreply@mail.lufthansa.com')).toBe(bookingEventUid(card, 'lufthansa.com')); + }); + + it('keys on the IATA code, and on the airline name only when there is no code', async () => { + const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']; + const short = await booking(lh(times)); + const long = await booking(lh(times, { airline: { name: 'Deutsche Lufthansa AG', iataCode: 'LH' } })); + expect(bookingEventUid(long, 'lufthansa.com')).toBe(bookingEventUid(short, 'lufthansa.com')); + const nameOnly = await booking(lh(times, { airline: { name: 'Lufthansa' } })); + const otherName = await booking(lh(times, { airline: { name: 'Lufthansa Group' } })); + expect((nameOnly as TransportBooking).legs[0].carrierCode).toBeUndefined(); + expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(otherName, 'lufthansa.com')); + expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(short, 'lufthansa.com')); + }); + + it('adds the departure date only when there is no reference', async () => { + const noRef = (time: [string, string]) => ({ ...lh(time), reservationNumber: undefined }); + const day1 = await booking(noRef(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + const day1Later = await booking(noRef(['2026-12-01T14:15:00+01:00', '2026-12-01T17:05:00-05:00'])); + const day2 = await booking(noRef(['2026-12-02T10:15:00+01:00', '2026-12-02T13:05:00-05:00'])); + expect(bookingEventUid(day1Later, 'lufthansa.com')).toBe(bookingEventUid(day1, 'lufthansa.com')); + 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('keys stays on reference and hotel, adding the check-in date only without reference', async () => { + const stay = (ref: string | undefined, checkin: string) => ({ + '@context': ctx, '@type': 'LodgingReservation', ...(ref ? { reservationNumber: ref } : {}), + reservationFor: { '@type': 'Hotel', name: 'Hotel Adlon Kempinski' }, checkinDate: checkin, checkoutDate: '2026-12-09', + }); + const a = await booking(stay('HB-1', '2026-12-04')); + const moved = await booking(stay('HB-1', '2026-12-05')); + expect(bookingEventUid(moved, 'kempinski.com')).toBe(bookingEventUid(a, 'kempinski.com')); + const b = await booking(stay(undefined, '2026-12-04')); + const c = await booking(stay(undefined, '2026-12-05')); + expect(bookingEventUid(b, 'kempinski.com')).not.toBe(bookingEventUid(c, 'kempinski.com')); + }); +}); + +describe('eventNeedsUpdate', () => { + /** What CalendarEvent/get returns for an event created from `wanted`. */ + function serverShaped(wanted: Partial): CalendarEvent { + const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([id, loc]) => [id, { + ...loc, description: null, locationTypes: null, coordinates: null, links: null, + timeZone: loc.timeZone ?? null, relativeTo: loc.relativeTo ?? null, iCalendar: { name: 'vlocation' }, + }])); + return { + id: 'b', calendarIds: { a: true }, isDraft: false, isOrigin: true, utcStart: '2026-12-05T04:15:00Z', utcEnd: '2026-12-05T11:30:00Z', + sequence: 0, created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z', status: 'confirmed', freeBusyStatus: 'busy', + privacy: 'public', color: null, keywords: null, categories: null, locale: null, replyTo: null, organizerCalendarAddress: null, + participants: null, recurrenceRules: null, recurrenceOverrides: null, alerts: null, useDefaultAlerts: false, virtualLocations: null, links: null, + ...wanted, + locations, + } as unknown as CalendarEvent; + } + + it('sees no change in the event the server gives back for the same booking', async () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER); + expect(eventNeedsUpdate(serverShaped(wanted), wanted)).toBe(false); + }); + + it('sees a changed start, duration, zone, title or location zone', 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 () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), 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'); + }); +}); + +describe('bookingEndsAt', () => { + it('ends a trip at its last arrival and a stay at the end of the check-out day', async () => { + expect(bookingEndsAt(await booking(SFO_JFK))).toBe(Date.parse('2026-12-05T11:30:00Z')); + const stay = await booking({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-2', reservationFor: { '@type': 'Hotel', name: 'Hotel' }, + checkinDate: '2026-12-04', checkoutDate: '2026-12-06', + }); + expect(bookingEndsAt(stay)).toBe(new Date(2026, 11, 7).getTime()); + }); +}); diff --git a/lib/structured-mail/calendar.ts b/lib/structured-mail/calendar.ts new file mode 100644 index 000000000..3334333fe --- /dev/null +++ b/lib/structured-mail/calendar.ts @@ -0,0 +1,625 @@ +/** + * Calendar events for booking cards: one JSCalendar event per transport leg + * and one per hotel stay, rental car, event or table booking. Plain + * TypeScript, no React and no stores, so the native app can reuse it. + * + * Times: a timed event starts at the departure's wall clock in its zone and + * lasts the exact hours and minutes between the two UTC instants (never a + * `D` duration, so a DST change in between can't move the end). The arrival + * zone goes on the arrival location (`relativeTo: "end"`). Floating times + * (no offset, no known zone) stay floating. Every event gets a uid derived + * from the sender's organisational domain and the booking's identity, so a + * later mail about the same booking finds it again and a mail from another + * domain never can. + */ +import type { CalendarEvent, CalendarLocation } from '@/lib/jmap/types'; +import { fixedOffsetZone, wallClockAt } from './time-zones'; +import type { + Address, + Booking, + Moment, + Place, + StructuredCard, + TransportBooking, + TransportLeg, +} from './types'; + +const DAY = 86_400_000; +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`. + */ +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; + return labels.slice(-count).join('.'); +} + +/** The domain part of an address (`orders@booking.flixbus.com` → `booking.flixbus.com`). */ +export function senderDomainOf(address: string | undefined): string { + if (!address) return ''; + const at = address.lastIndexOf('@'); + return (at >= 0 ? address.slice(at + 1) : address).trim().toLowerCase(); +} + +function utf8Bytes(text: string): number[] { + const bytes: number[] = []; + for (const char of text) { + const cp = char.codePointAt(0) ?? 0; + if (cp < 0x80) bytes.push(cp); + else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); + else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + } + return bytes; +} + +/** + * 64-bit FNV-1a of the UTF-8 bytes, as 16 hex digits. Synchronous and on + * plain numbers (four 16-bit limbs), so it runs without crypto.subtle (Lite + * on plain http) and without BigInt. + */ +export function fnv1a64(text: string): string { + // Offset basis 0xcbf29ce484222325, lowest limb first. + let h0 = 0x2325, h1 = 0x8422, h2 = 0x9ce4, h3 = 0xcbf2; + for (const byte of utf8Bytes(text)) { + h0 ^= byte; + // Multiply by the prime 0x100000001b3 = 2^40 + 0x1b3, modulo 2^64. + let r0 = h0 * 0x1b3; + let r1 = h1 * 0x1b3; + let r2 = h2 * 0x1b3 + h0 * 0x100; + let r3 = h3 * 0x1b3 + h1 * 0x100; + r1 += r0 >>> 16; r0 &= 0xffff; + r2 += r1 >>> 16; r1 &= 0xffff; + r3 += r2 >>> 16; r2 &= 0xffff; + r3 &= 0xffff; + h0 = r0; h1 = r1; h2 = r2; h3 = r3; + } + return [h3, h2, h1, h0].map((limb) => limb.toString(16).padStart(4, '0')).join(''); +} + +const norm = (value: string | undefined) => (value ?? '').toLowerCase().replace(/\s+/g, ' ').trim(); + +function uidFrom(kind: Booking['kind'], parts: (string | undefined)[]): string { + return `bulwark-sm-${kind}-${fnv1a64(parts.map(norm).join('|'))}@${UID_DOMAIN}`; +} + +/** + * 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. + */ +function legParts(booking: TransportBooking, leg: TransportLeg, withDate: boolean): (string | undefined)[] { + return [ + booking.reference, + leg.carrierCode ?? leg.carrier, + leg.number, + leg.departure.place.code ?? leg.departure.place.name, + ...(withDate || !booking.reference ? [leg.departure.at.date] : []), + ]; +} + +/** + * The uid of the event for a booking (or, for flights, trains and buses, for + * one of its legs; the first leg when none is given): + * `bulwark-sm--<16 hex>@structured.bulwark`, hashed over a key that + * 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)); + 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 'lodging': + return [uidFrom('lodging', [domain, booking.reference, booking.name, ...(booking.reference ? [] : [booking.checkin.date])])]; + case 'rentalcar': + return [uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, booking.pickup.at.date])]; + case 'event': case 'restaurant': + return [uidFrom(booking.kind, [domain, booking.reference, booking.name, booking.start.date])]; + } +} + +// --------------------------------------------------------------------------- +// times + +interface Instant { + /** Local wall clock, `YYYY-MM-DDTHH:mm:ss`, in `timeZone`. */ + start: string; + /** IANA zone, or null for a floating time. */ + timeZone: string | null; + /** UTC instant in ms; null when floating. */ + utcMs: number | null; + /** The wall clock as the mail wrote it, read as if it were UTC (for floating differences). */ + naiveMs: number; +} + +function naiveMs(m: Moment): number { + return Date.parse(`${m.date}T${m.time ?? '00:00:00'}Z`); +} + +/** + * Where a timed moment sits: its resolved zone and instant. A moment that + * resolveCards hasn't seen but that carries an offset gets the fixed-offset + * zone; one without offset or zone stays floating. + */ +function instantOf(m: Moment | undefined): Instant | null { + if (!m?.time || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null; + const naive = naiveMs(m); + if (!Number.isFinite(naive)) return null; + const zone = m.zone !== undefined ? m.zone : (m.offsetMinutes !== undefined ? fixedOffsetZone(m.offsetMinutes) : null); + if (zone === null) return { start: `${m.date}T${m.time}`, timeZone: null, utcMs: null, naiveMs: naive }; + const utcMs = m.utc ? Date.parse(m.utc) : NaN; + if (!Number.isFinite(utcMs)) return null; + const wall = wallClockAt(utcMs, zone); + return { start: `${wall.date}T${wall.time}`, timeZone: zone, utcMs, naiveMs: naive }; +} + +/** Hours, minutes and seconds only (`PT7H15M`, `PT43H`, `PT0S`). */ +function timedDuration(ms: number): string { + const total = Math.round(ms / 1000); + if (total <= 0) return 'PT0S'; + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s ? `${s}S` : ''}`; +} + +/** + * The exact span between two timed moments: from the UTC instants when both + * have one, else from the wall clocks as written. Null when the end lies + * before the start (resolveCards drops such bookings; this is a guard). + */ +function spanBetween(start: Instant, end: Instant): number | null { + const ms = start.utcMs !== null && end.utcMs !== null ? end.utcMs - start.utcMs : end.naiveMs - start.naiveMs; + return ms >= 0 ? ms : null; +} + +function dayNumber(date: string): number | null { + const ms = Date.parse(`${date}T00:00:00Z`); + return Number.isFinite(ms) ? Math.round(ms / DAY) : null; +} + +/** An all-day span from `first` to `last`, both days included. */ +function allDaySpan(first: string, last: string): { start: string; duration: string } | null { + const a = dayNumber(first); + const b = dayNumber(last); + if (a === null || b === null || b < a) return null; + return { start: `${first}T00:00:00`, duration: `P${b - a + 1}D` }; +} + +/** + * When a moment is over, in ms: its instant; a floating time read in the + * runtime's zone; a date-only value at the end of that day (local). + */ +function momentOverAt(m: Moment | undefined): number | null { + if (!m || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null; + if (m.time && m.utc) return Date.parse(m.utc); + const [y, mo, d] = m.date.split('-').map(Number); + if (m.time) { + const [h, mi, s] = m.time.split(':').map(Number); + return new Date(y, mo - 1, d, h, mi, s || 0).getTime(); + } + return new Date(y, mo - 1, d + 1).getTime(); +} + +/** + * When the booking is over (ms): the last arrival (or departure, for a leg + * without arrival), check-out, drop-off, the event's end or start, the + * table's time. Null when no date can be read. + */ +export function bookingEndsAt(booking: Booking): number | null { + const ends: (number | null)[] = []; + switch (booking.kind) { + case 'flight': case 'train': case 'bus': + for (const leg of booking.legs) ends.push(momentOverAt(leg.arrival?.at ?? leg.departure.at)); + break; + case 'lodging': ends.push(momentOverAt(booking.checkout)); break; + case 'rentalcar': ends.push(momentOverAt(booking.dropoff.at)); break; + case 'event': ends.push(momentOverAt(booking.end ?? booking.start)); break; + case 'restaurant': ends.push(momentOverAt(booking.start)); break; + } + const known = ends.filter((e): e is number => e !== null && Number.isFinite(e)); + return known.length > 0 ? Math.max(...known) : null; +} + +// --------------------------------------------------------------------------- +// text + +/** Description lines and the one title word that need the user's language. */ +export interface BookingEventLabels { + reference: (reference: string) => string; + passengers: (names: string) => string; + seats: (seats: string) => string; + checkin: (url: string) => string; + manage: (url: string) => string; + ticket: (url: string) => string; + phone: (phone: string) => string; + car: (car: string) => string; + tickets: (numbers: string) => string; + partySize: (count: number) => string; + rentalCar: (company: string | undefined) => string; + addedFrom: (domain: string, subject: string | undefined) => string; +} + +export const ENGLISH_LABELS: BookingEventLabels = { + reference: (reference) => `Booking reference: ${reference}`, + passengers: (names) => `Passengers: ${names}`, + seats: (seats) => `Seats: ${seats}`, + checkin: (url) => `Check in: ${url}`, + manage: (url) => `Manage booking: ${url}`, + ticket: (url) => `Ticket: ${url}`, + phone: (phone) => `Phone: ${phone}`, + car: (car) => `Car: ${car}`, + tickets: (numbers) => `Tickets: ${numbers}`, + partySize: (count) => `Party size: ${count}`, + rentalCar: (company) => (company ? `Rental car: ${company}` : 'Rental car'), + addedFrom: (domain, subject) => (subject ? `Added from the mail "${subject}" from ${domain}` : `Added from a mail from ${domain}`), +}; + +function addressLines(address: Address | undefined): string[] { + if (!address) return []; + const city = [address.postalCode, address.locality].filter(Boolean).join(' '); + return [address.street, city].filter((line): line is string => !!line && line.trim() !== ''); +} + +/** "Name, Street 1, 12345 City", skipping parts that repeat. */ +function joinParts(parts: (string | undefined)[]): string { + const out: string[] = []; + for (const part of parts) { + const value = part?.trim(); + if (value && !out.some((p) => p.toLowerCase() === value.toLowerCase())) out.push(value); + } + return out.join(', '); +} + +/** "Frankfurt Airport (FRA)" for airports, name and address elsewhere. */ +function placeName(place: Place | undefined, kind: Booking['kind']): string { + if (!place) return ''; + if (kind === 'flight') { + if (place.name && place.code) return place.name.toUpperCase().includes(`(${place.code})`) ? place.name : `${place.name} (${place.code})`; + return place.code ?? place.name ?? ''; + } + return joinParts([place.name, ...addressLines(place.address)]); +} + +function shortPlace(place: Place | undefined, kind: Booking['kind']): string { + if (!place) return ''; + return (kind === 'flight' ? place.code ?? place.name : place.name ?? place.code) ?? ''; +} + +function legTitle(kind: TransportBooking['kind'], leg: TransportLeg): string { + let service: string; + if (kind === 'flight') { + service = [leg.carrierCode ?? leg.carrier, leg.number].filter(Boolean).join(' '); + } else if (leg.number && /[a-z]/i.test(leg.number)) { + service = leg.number; + } else { + service = [leg.carrier, leg.number].filter(Boolean).join(' '); + } + const route = [shortPlace(leg.departure.place, kind), shortPlace(leg.arrival?.place, kind)].filter(Boolean).join(' → '); + return [service, route].filter(Boolean).join(' '); +} + +function passengerLines(booking: Booking, leg: TransportLeg | undefined, labels: BookingEventLabels): string[] { + const seats = leg?.seats ?? []; + const seatsOf = (name: string) => seats.filter((s) => s.passenger === name).map((s) => s.seat); + const names = booking.passengers.map((name) => { + const own = seatsOf(name); + return own.length > 0 ? `${name} (${own.join(', ')})` : name; + }); + const unnamed = seats.filter((s) => !s.passenger || !booking.passengers.includes(s.passenger)).map((s) => s.seat); + const lines: string[] = []; + if (names.length > 0) lines.push(labels.passengers(names.join(', '))); + if (unnamed.length > 0) lines.push(labels.seats(unnamed.join(', '))); + return lines; +} + +function linkLines(booking: Booking, labels: BookingEventLabels): string[] { + const lines: string[] = []; + if (booking.links.checkin) lines.push(labels.checkin(booking.links.checkin.url)); + if (booking.links.manage) lines.push(labels.manage(booking.links.manage.url)); + if (booking.links.ticket) lines.push(labels.ticket(booking.links.ticket.url)); + return lines; +} + +// --------------------------------------------------------------------------- +// events + +export interface BookingEventOptions { + /** Domain (or address) of the mail's From: names the sender and keys the uid. */ + senderDomain: string; + /** Subject of the mail, named in the description so the user can find it. */ + mailSubjectOrProvider?: string; + /** Translated description lines; English by default. */ + labels?: Partial; +} + +/** One event the booking wants in the calendar, or, for a cancelled leg, one it wants gone. */ +export interface BookingEventSlot { + uid: string; + /** The leg or booking is cancelled: never added, removed when present. */ + cancelled: boolean; + /** The event to create or compare with; null when the times can't be made into one. */ + event: Partial | null; +} + +type Locations = Record; + +function location(name: string, timeZone: string | null | undefined, relativeTo?: 'start' | 'end'): CalendarLocation { + return { + '@type': 'Location', + name, + ...(timeZone ? { timeZone } : {}), + ...(relativeTo ? { relativeTo } : {}), + } as CalendarLocation; +} + +function event( + uid: string, + title: string, + when: { start: string; timeZone: string | null; duration: string; showWithoutTime: boolean }, + locations: Locations, + description: string[], + extra: Partial = {}, +): Partial { + return { + '@type': 'Event', + uid, + title, + start: when.start, + timeZone: when.timeZone, + duration: when.duration, + showWithoutTime: when.showWithoutTime, + ...(Object.keys(locations).length > 0 ? { locations } : {}), + description: description.filter(Boolean).join('\n'), + ...extra, + }; +} + +/** A timed span from `from` to `to` (or zero length without `to`), or null when it can't be told exactly. */ +function timedSpan(from: Moment, to: Moment | undefined) { + const start = instantOf(from); + if (!start) return null; + if (!to) return { start, end: null, when: { start: start.start, timeZone: start.timeZone, duration: 'PT0S', showWithoutTime: false } }; + const end = instantOf(to); + if (!end) return null; + const ms = spanBetween(start, end); + if (ms === null) return null; + return { start, end, when: { start: start.start, timeZone: start.timeZone, duration: timedDuration(ms), showWithoutTime: false } }; +} + +function legEvent(booking: TransportBooking, leg: TransportLeg, uid: string, tail: string[], labels: BookingEventLabels): Partial | null { + const span = timedSpan(leg.departure.at, leg.arrival?.at); + if (!span) return null; + const locations: Locations = { + dep: location(placeName(leg.departure.place, booking.kind), span.start.timeZone, 'start'), + }; + if (leg.arrival && placeName(leg.arrival.place, booking.kind)) { + locations.arr = location(placeName(leg.arrival.place, booking.kind), span.end?.timeZone, 'end'); + } + return event(uid, legTitle(booking.kind, leg), span.when, locations, [ + booking.reference ? labels.reference(booking.reference) : '', + ...passengerLines(booking, leg, labels), + ...linkLines(booking, labels), + ...tail, + ]); +} + +function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSlot[] { + const labels: BookingEventLabels = { ...ENGLISH_LABELS, ...options.labels }; + const domain = organizationalDomain(senderDomainOf(options.senderDomain)); + const tail = [labels.addedFrom(domain, options.mailSubjectOrProvider?.trim() || undefined)]; + const reference = booking.reference ? labels.reference(booking.reference) : ''; + const cancelled = booking.status === 'cancelled'; + + 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), + })); + } + case 'lodging': { + const [uid] = bookingUids(booking, options.senderDomain); + const hotel = joinParts([booking.name, ...addressLines(booking.address)]); + const description = [ + reference, + ...passengerLines(booking, undefined, labels), + booking.phone ? labels.phone(booking.phone) : '', + ...linkLines(booking, labels), + ...tail, + ]; + const timed = booking.checkin.time && booking.checkout.time ? timedSpan(booking.checkin, booking.checkout) : null; + if (timed) { + return [{ + uid, cancelled, + event: event(uid, booking.name, timed.when, { hotel: location(hotel, timed.start.timeZone) }, description, { freeBusyStatus: 'free' }), + }]; + } + const days = allDaySpan(booking.checkin.date, booking.checkout.date); + return [{ + uid, cancelled, + event: days + ? event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, { hotel: location(hotel, null) }, description, { freeBusyStatus: 'free' }) + : null, + }]; + } + case 'rentalcar': { + const [uid] = bookingUids(booking, options.senderDomain); + const span = timedSpan(booking.pickup.at, booking.dropoff.at); + const locations: Locations = {}; + if (span) { + const pickup = placeName(booking.pickup.place, booking.kind); + const dropoff = placeName(booking.dropoff.place, booking.kind); + if (pickup) locations.pickup = location(pickup, span.start.timeZone, 'start'); + if (dropoff) locations.dropoff = location(dropoff, span.end?.timeZone, 'end'); + } + return [{ + uid, cancelled, + event: span + ? event(uid, labels.rentalCar(booking.company ?? booking.provider), span.when, locations, [ + reference, + booking.car ? labels.car(booking.car) : '', + ...linkLines(booking, labels), + ...tail, + ], { freeBusyStatus: 'free' }) + : null, + }]; + } + case 'event': { + const [uid] = bookingUids(booking, options.senderDomain); + const description = [ + reference, + booking.tickets.length > 0 ? labels.tickets(booking.tickets.join(', ')) : '', + ...linkLines(booking, labels), + ...tail, + ]; + let made: Partial | null = null; + if (booking.start.time) { + // An end date without time already became 23:59:59 in the parser; no + // end at all is zero length (Stalwart would stretch a missing + // duration to the end of the day). + const span = timedSpan(booking.start, booking.end); + const venue = placeName(booking.venue, booking.kind); + if (span) made = event(uid, booking.name, span.when, venue ? { venue: location(venue, span.start.timeZone) } : {}, description); + } else { + const days = allDaySpan(booking.start.date, booking.end?.date ?? booking.start.date); + const venue = placeName(booking.venue, booking.kind); + if (days) made = event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, venue ? { venue: location(venue, null) } : {}, description); + } + return [{ uid, cancelled, event: made }]; + } + case 'restaurant': { + const [uid] = bookingUids(booking, options.senderDomain); + const span = timedSpan(booking.start, undefined); + const place = joinParts([booking.name, ...addressLines(booking.address)]); + return [{ + uid, cancelled, + event: span + ? event(uid, booking.name, span.when, { restaurant: location(place, span.start.timeZone) }, [ + reference, + booking.partySize ? labels.partySize(booking.partySize) : '', + ...linkLines(booking, labels), + ...tail, + ]) + : null, + }]; + } + } +} + +/** + * Every event a booking stands for, cancelled ones included (the caller + * offers to remove those). Nothing for shipments. + */ +export function bookingEventSlots(card: StructuredCard, options: BookingEventOptions): BookingEventSlot[] { + if (card.kind === 'shipment') return []; + return slotsOf(card, options); +} + +/** + * The events to add for a booking: one per live leg, or one for the stay, + * car, event or table. Cancelled legs give none. No calendarIds (the caller + * picks the calendar), no participants, never scheduling messages. + */ +export function bookingToEvents(booking: Booking, options: BookingEventOptions): Partial[] { + return slotsOf(booking, options) + .filter((slot) => !slot.cancelled && slot.event) + .map((slot) => slot.event as Partial); +} + +// --------------------------------------------------------------------------- +// comparing with what the calendar holds + +const DURATION = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/; + +/** Nominal days and exact seconds of a duration; P1D and PT24H differ. */ +function durationParts(value: string | undefined | null): string { + const match = value ? DURATION.exec(value) : null; + if (!match) return value ?? ''; + const days = Number(match[1] ?? 0) * 7 + Number(match[2] ?? 0); + const seconds = Number(match[3] ?? 0) * 3600 + Number(match[4] ?? 0) * 60 + Number(match[5] ?? 0); + return `${days}d${seconds}s`; +} + +const localDateTime = (value: string | undefined | null) => { + const v = value ?? ''; + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v) ? `${v}:00` : v.slice(0, 19); +}; + +/** Location ids this module writes; other locations on the event are the user's. */ +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). + */ +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; +} + +/** + * The patch that brings a stored event to the booking's current state. The + * user's own locations stay; ours are replaced. + */ +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; + } + 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 } : {}), + }; +} From b9b26e03a7bab2e064c971e727d7052a5b359259 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:10:33 +0100 Subject: [PATCH 2/6] fix: give a boarding pass leg the same calendar uid as its booking mail --- lib/structured-mail/__tests__/calendar.test.ts | 10 ++++++++++ lib/structured-mail/calendar.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/structured-mail/__tests__/calendar.test.ts b/lib/structured-mail/__tests__/calendar.test.ts index 3126aea3e..79c79f4cd 100644 --- a/lib/structured-mail/__tests__/calendar.test.ts +++ b/lib/structured-mail/__tests__/calendar.test.ts @@ -350,6 +350,16 @@ describe('bookingEventUid', () => { expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(short, 'lufthansa.com')); }); + it('ignores leading zeros of the flight number, so a boarding pass finds the markup event', async () => { + const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']; + const padded = (await booking(lh(times, { number: '0072' }))) as TransportBooking; + const plain = (await booking(lh(times, { number: '72' }))) as TransportBooking; + expect(padded.legs[0].number).toBe('0072'); + expect(bookingEventUid(padded, 'lufthansa.com')).toBe(bookingEventUid(plain, 'lufthansa.com')); + const other = await booking(lh(times, { number: '720' })); + expect(bookingEventUid(other, 'lufthansa.com')).not.toBe(bookingEventUid(plain, 'lufthansa.com')); + }); + it('adds the departure date only when there is no reference', async () => { const noRef = (time: [string, string]) => ({ ...lh(time), reservationNumber: undefined }); const day1 = await booking(noRef(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); diff --git a/lib/structured-mail/calendar.ts b/lib/structured-mail/calendar.ts index 3334333fe..67cbd1f8c 100644 --- a/lib/structured-mail/calendar.ts +++ b/lib/structured-mail/calendar.ts @@ -103,7 +103,8 @@ function legParts(booking: TransportBooking, leg: TransportLeg, withDate: boolea return [ booking.reference, leg.carrierCode ?? leg.carrier, - leg.number, + // "LX0072" in the markup is flight 72 on the boarding pass (legKey does the same). + norm(leg.number).replace(/^0+(?=\d)/, ''), leg.departure.place.code ?? leg.departure.place.name, ...(withDate || !booking.reference ? [leg.departure.at.date] : []), ]; From fe5f10a7ea972c9270452e3e170d32d9dc849562 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:11:51 +0100 Subject: [PATCH 3/6] feat: add, update and remove bookings from mail in the calendar --- .../__tests__/use-booking-calendar.test.tsx | 299 +++++++++++++++++ .../booking-calendar-actions.tsx | 199 +++++++++++ .../structured-mail/use-booking-calendar.ts | 313 ++++++++++++++++++ locales/ar/common.json | 30 ++ locales/ca/common.json | 30 ++ locales/cs/common.json | 30 ++ locales/da/common.json | 30 ++ locales/de/common.json | 30 ++ locales/en/common.json | 30 ++ locales/es/common.json | 30 ++ locales/et/common.json | 30 ++ locales/fa/common.json | 30 ++ locales/fr/common.json | 30 ++ locales/he/common.json | 30 ++ locales/hu/common.json | 30 ++ locales/it/common.json | 30 ++ locales/ja/common.json | 30 ++ locales/ko/common.json | 30 ++ locales/lv/common.json | 30 ++ locales/mn/common.json | 30 ++ locales/nb/common.json | 30 ++ locales/nl/common.json | 30 ++ locales/pl/common.json | 30 ++ locales/pt/common.json | 30 ++ locales/ro/common.json | 30 ++ locales/ru/common.json | 30 ++ locales/sk/common.json | 30 ++ locales/th/common.json | 30 ++ locales/tr/common.json | 30 ++ locales/uk/common.json | 30 ++ locales/zh-TW/common.json | 30 ++ locales/zh/common.json | 30 ++ 32 files changed, 1681 insertions(+) create mode 100644 components/email/structured-mail/__tests__/use-booking-calendar.test.tsx create mode 100644 components/email/structured-mail/booking-calendar-actions.tsx create mode 100644 components/email/structured-mail/use-booking-calendar.ts diff --git a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx new file mode 100644 index 000000000..3018c2b6b --- /dev/null +++ b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx @@ -0,0 +1,299 @@ +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Calendar, CalendarEvent, Email } from '@/lib/jmap/types'; +import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard } from '@/lib/structured-mail'; +import { bookingToEvents } from '@/lib/structured-mail/calendar'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { BookingCalendarActions } from '../booking-calendar-actions'; +import { useBookingCalendar } from '../use-booking-calendar'; + +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 { + const html = `${items.map((i) => ``).join('')}`; + 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, 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: null, utcEnd: null, 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 => ({ + id, name: id, color: '#3366ff', isDefault: false, isSubscribed: true, isVisible: true, sortOrder: 0, description: null, + includeInAvailability: 'all', defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, + myRights: rights(true), ...extra, +} as Calendar); + +const client = { queryCalendarEvents: vi.fn() }; +const createEvent = vi.fn(); +const updateEvent = vi.fn(); +const deleteEvent = vi.fn(); + +const initialCalendar = useCalendarStore.getState(); +const initialAuth = useAuthStore.getState(); +const initialPolicy = usePolicyStore.getState(); + +function setCalendarGate(on: boolean) { + usePolicyStore.setState((s) => ({ policy: { ...s.policy, features: { ...s.policy.features, calendarEnabled: on } } })); +} + +/** The calendar holds these events, found by uid. */ +function calendarHolds(events: CalendarEvent[]) { + client.queryCalendarEvents.mockImplementation(async (filter: { uid: string }) => events.filter((e) => e.uid === filter.uid)); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + client.queryCalendarEvents.mockReset(); + calendarHolds([]); + createEvent.mockReset().mockImplementation(async (_client, event: Partial) => ({ ...event, id: `new-${event.uid}` })); + updateEvent.mockReset().mockResolvedValue(true); + deleteEvent.mockReset().mockResolvedValue(true); + setCalendarGate(true); + useAuthStore.setState({ client: client as never, activeAccountId: null }); + useCalendarStore.setState({ + supportsCalendar: true, + calendars: [cal('cal-default', { isDefault: true })], + events: [], + icalSubscriptions: [], + createEvent, updateEvent, deleteEvent, + setSelectedDate: vi.fn(), + }); +}); + +afterEach(() => { + vi.useRealTimers(); + useCalendarStore.setState(initialCalendar, true); + useAuthStore.setState(initialAuth, true); + usePolicyStore.setState(initialPolicy, true); +}); + +async function settled(card: StructuredCard) { + const hook = renderHook(({ b }) => useBookingCalendar(b, EMAIL), { initialProps: { b: card } }); + await waitFor(() => expect(hook.result.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 { result } = await settled(booking); + expect(result.current.status).toBe('add'); + expect(client.queryCalendarEvents).toHaveBeenCalledTimes(2); + + await act(() => result.current.add('cal-default')); + expect(createEvent).toHaveBeenCalledTimes(2); + const [, first] = createEvent.mock.calls[0]; + const wanted = bookingToEvents(booking, { senderDomain: 'lufthansa.com' })[0]; + expect(first).toMatchObject({ + uid: wanted.uid, calendarIds: { 'cal-default': true }, title: 'LH 400 FRA → JFK', + start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT8H50M', + }); + expect(first).not.toHaveProperty('participants'); + expect(createEvent.mock.calls[0]).toHaveLength(2); + expect(result.current.status).toBe('in-calendar'); + expect(result.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 { result } = await settled(booking); + expect(result.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 before = await bookingFrom([OUT]); + const [old] = bookingToEvents(before, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + const { result } = await settled(await bookingFrom([OUT_MOVED])); + expect(result.current.status).toBe('update'); + + await act(() => result.current.update()); + expect(createEvent).not.toHaveBeenCalled(); + expect(updateEvent).toHaveBeenCalledTimes(1); + const [, id, patch, scheduling] = updateEvent.mock.calls[0]; + expect(id).toBe('e1'); + expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' }); + expect(scheduling).toBeUndefined(); + expect(result.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 { result } = await settled(booking); + expect(result.current.status).toBe('update'); + await act(() => result.current.update()); + expect(createEvent).toHaveBeenCalledTimes(1); + expect(createEvent.mock.calls[0][1]).toMatchObject({ title: 'LH 401 JFK → FRA', calendarIds: { 'cal-travel': true } }); + 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 { result } = await settled(await bookingFrom([OUT_CANCELLED])); + expect(result.current.status).toBe('remove'); + await act(() => result.current.remove()); + expect(deleteEvent).toHaveBeenCalledWith(client, 'e1'); + expect(createEvent).not.toHaveBeenCalled(); + expect(result.current.status).toBe('cancelled'); + expect(result.current.notice).toBe('removed'); + }); + + it('offers nothing for a cancelled booking the calendar never had', async () => { + const { result } = await settled(await bookingFrom([OUT_CANCELLED])); + expect(result.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 { result } = await settled(booking); + expect(result.current.status).toBe('add'); + await act(() => result.current.add()); + expect(createEvent).toHaveBeenCalledTimes(1); + expect(createEvent.mock.calls[0][1]).toMatchObject({ title: 'LH 400 FRA → JFK' }); + + const [a, b] = bookingToEvents(await bookingFrom([OUT, BACK]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(a, 'e1'), stored(b, 'e2')]); + createEvent.mockClear(); + const second = await settled(booking); + expect(second.result.current.status).toBe('update'); + await act(() => second.result.current.update()); + expect(deleteEvent).toHaveBeenCalledWith(client, '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 { result } = await settled(booking); + expect(result.current.status).toBe('add'); + }); + + it('shows nothing for a booking that is over', async () => { + vi.setSystemTime(new Date('2026-12-02T12:00:00Z')); + const { result } = renderHook(() => useBookingCalendar(null, EMAIL)); + expect(result.current.status).toBe('hidden'); + const booking = await bookingFrom([OUT]); + const past = renderHook(() => useBookingCalendar(booking, EMAIL)); + expect(past.result.current.status).toBe('hidden'); + expect(client.queryCalendarEvents).not.toHaveBeenCalled(); + }); + + it('shows nothing with the admin calendar gate off or without calendar support', async () => { + const booking = await bookingFrom([OUT]); + setCalendarGate(false); + expect(renderHook(() => useBookingCalendar(booking, EMAIL)).result.current.status).toBe('hidden'); + setCalendarGate(true); + useCalendarStore.setState({ supportsCalendar: false }); + expect(renderHook(() => useBookingCalendar(booking, EMAIL)).result.current.status).toBe('hidden'); + expect(client.queryCalendarEvents).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)).result.current.status).toBe('hidden'); + }); + + it('shows nothing when the lookup fails, rather than risk a duplicate', async () => { + client.queryCalendarEvents.mockRejectedValue(new Error('offline')); + const { result } = await settled(await bookingFrom([OUT])); + expect(result.current.status).toBe('hidden'); + }); + + it('says why an add failed', async () => { + createEvent.mockResolvedValue(null); + const { result } = await settled(await bookingFrom([OUT])); + await act(() => result.current.add()); + expect(result.current.error).toBe('action_failed'); + expect(result.current.status).toBe('add'); + }); +}); + +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', { isTasksOnly: true }), + cal('Holidays', { myRights: rights(false) }), + cal('Team', { isShared: true, accountId: 'other' }), + cal('Feed'), + ], + icalSubscriptions: [{ id: 'sub', calendarId: 'Feed', url: 'x', name: 'Feed', color: '#000', refreshInterval: 60 } as never], + }); + render(); + fireEvent.click(await screen.findByRole('button', { name: /add_to_calendar/ })); + const items = screen.getAllByRole('menuitem').map((item) => item.textContent); + expect(items).toEqual(['Personal', 'Work']); + fireEvent.click(screen.getByRole('menuitem', { name: 'Work' })); + await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1)); + expect(createEvent.mock.calls[0][1]).toMatchObject({ calendarIds: { Work: true } }); + expect(await screen.findByText('added')).toBeTruthy(); + }); + + 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 { rerender } = render(); + fireEvent.click(await screen.findByRole('button', { name: /add_to_calendar/ })); + expect(screen.queryByRole('menu')).toBeNull(); + await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1)); + expect(createEvent.mock.calls[0][1]).toMatchObject({ calendarIds: { 'cal-default': true } }); + + const [wanted] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(wanted, 'e1')]); + rerender(); + expect(await screen.findByText('in_calendar')).toBeTruthy(); + expect(screen.getByRole('button', { name: /view_in_calendar/ })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /add_to_calendar/ })).toBeNull(); + }); + + 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 { container } = render(); + await act(async () => { await Promise.resolve(); }); + expect(container.innerHTML).toBe(''); + expect(client.queryCalendarEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/structured-mail/booking-calendar-actions.tsx b/components/email/structured-mail/booking-calendar-actions.tsx new file mode 100644 index 000000000..657229845 --- /dev/null +++ b/components/email/structured-mail/booking-calendar-actions.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslations } from 'next-intl'; +import { + ArrowRight, + Calendar as CalendarIcon, + CalendarCheck, + CalendarPlus, + CalendarX, + ChevronDown, + Loader2, +} from '@/components/icons'; +import { sanitizeColor } from '@/components/calendar/event-card'; +import { isDocumentRTL } from '@/i18n/direction'; +import type { Email } from '@/lib/jmap/types'; +import type { StructuredCard } from '@/lib/structured-mail'; +import { useBookingCalendar } from './use-booking-calendar'; + +const BUTTON = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50'; +const PICKER_WIDTH = 208; +const EDGE = 8; + +interface PickerPosition { top: number; left?: number; right?: number; maxHeight: number } + +/** Where the picker goes: under the trigger, flipped above when there's no room, always inside the viewport. */ +function pickerPosition(rect: DOMRect, count: number): PickerPosition { + const width = Math.min(PICKER_WIDTH, window.innerWidth - 2 * EDGE); + const wanted = Math.min(36 * count + 40, 320); + const below = window.innerHeight - rect.bottom - 4 - EDGE; + const above = rect.top - 4 - EDGE; + const placeBelow = below >= wanted || below >= above; + const maxHeight = Math.max(120, Math.min(wanted, placeBelow ? below : above)); + const top = placeBelow ? rect.bottom + 4 : Math.max(EDGE, rect.top - 4 - maxHeight); + const clamp = (value: number) => Math.max(EDGE, Math.min(value, window.innerWidth - width - EDGE)); + return isDocumentRTL() + ? { top, right: clamp(window.innerWidth - rect.right), maxHeight } + : { top, left: clamp(rect.left), maxHeight }; +} + +/** + * 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 }) { + const t = useTranslations('structured_mail_calendar'); + const calendar = useBookingCalendar(booking, email); + const { status, calendars, busy, error, notice } = calendar; + const [picker, setPicker] = useState(null); + const triggerRef = useRef(null); + const pickerRef = useRef(null); + + const closePicker = useCallback(() => setPicker(null), []); + + useEffect(() => { + if (!picker) return; + const onPointer = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node; + if (pickerRef.current?.contains(target) || triggerRef.current?.contains(target)) return; + closePicker(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closePicker(); + triggerRef.current?.focus(); + } + }; + window.addEventListener('scroll', closePicker, true); + window.addEventListener('resize', closePicker); + document.addEventListener('mousedown', onPointer); + document.addEventListener('touchstart', onPointer); + document.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('scroll', closePicker, true); + window.removeEventListener('resize', closePicker); + document.removeEventListener('mousedown', onPointer); + document.removeEventListener('touchstart', onPointer); + document.removeEventListener('keydown', onKey); + }; + }, [picker, closePicker]); + + 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; + } + if (picker) { + closePicker(); + return; + } + if (triggerRef.current) setPicker(pickerPosition(triggerRef.current.getBoundingClientRect(), calendars.length)); + }; + + const noticeText = notice === 'added' ? t('added') : notice === 'updated' ? t('updated') : notice === 'removed' ? t('removed') : null; + const showView = status === 'in-calendar' || status === 'update'; + + return ( +
+ {status === 'add' && ( + <> + + {picker && calendars.length > 1 && typeof document !== 'undefined' && createPortal( +
+
{t('select_calendar')}
+ {calendars.map((cal) => ( + + ))} +
, + document.body, + )} + + )} + + {status === 'update' && ( + + )} + + {status === 'remove' && ( + + )} + + {status === 'in-calendar' && !noticeText && ( + {t('in_calendar')} + )} + + {noticeText && ( + {noticeText} + )} + + {showView && ( + + )} + + {busy && } + + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/components/email/structured-mail/use-booking-calendar.ts b/components/email/structured-mail/use-booking-calendar.ts new file mode 100644 index 000000000..5c30ea378 --- /dev/null +++ b/components/email/structured-mail/use-booking-calendar.ts @@ -0,0 +1,313 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useShallow } from 'zustand/react/shallow'; +import { useRouter } from '@/i18n/navigation'; +import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; +import { canCreateEventsIn } from '@/lib/calendar-editability'; +import { getEventStartDate } from '@/lib/calendar-utils'; +import type { Calendar, CalendarEvent, Email } from '@/lib/jmap/types'; +import { + bookingEndsAt, + bookingEventPatch, + bookingEventSlots, + eventNeedsUpdate, + senderDomainOf, + type BookingEventLabels, + type BookingEventSlot, +} from '@/lib/structured-mail/calendar'; +import type { StructuredCard } from '@/lib/structured-mail'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +/** + * What the card offers for its booking: + * - `hidden`: nothing (no calendar, admin gate off, 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; + /** Add the missing live events, update the changed ones, remove the cancelled ones. */ + update: () => Promise; + /** Remove the events of cancelled legs or of the cancelled booking. */ + remove: () => Promise; + viewInCalendar: () => void; +} + +type Lookup = { key: string; failed: false; events: Record } | { 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 of the primary calendar account an event can be created in, the default first. */ +export function bookingTargetCalendars( + calendars: Calendar[], + isSubscriptionCalendar: (calendarId: string) => boolean, + activeLocalAccountId: string | null, +): Calendar[] { + const own = calendars.filter((c) => + !c.isShared + && (!c.localAccountId || c.localAccountId === activeLocalAccountId) + && !c.isTasksOnly + && canCreateEventsIn(c, isSubscriptionCalendar)); + const defaultCalendar = own.find((c) => c.isDefault); + return defaultCalendar ? [defaultCalendar, ...own.filter((c) => c !== defaultCalendar)] : own; +} + +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)); + 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 + * (CalendarEvent/query by uid), merges what it finds into the calendar + * store, and never creates an event whose uid it found. Hidden unless the + * account has calendars, the admin allows them and the booking isn't over. + */ +export function useBookingCalendar(booking: StructuredCard | null | undefined, email: Pick | null | undefined): BookingCalendar { + const t = useTranslations('structured_mail_calendar'); + const describeError = useCalendarErrorMessage(); + const router = useRouter(); + const client = useAuthStore((s) => s.client); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); + const { + calendars, supportsCalendar, storeEvents, icalSubscriptions, + createEvent, updateEvent, deleteEvent, setSelectedDate, isSubscriptionCalendar, + } = useCalendarStore(useShallow((s) => ({ + calendars: s.calendars, + supportsCalendar: s.supportsCalendar, + storeEvents: s.events, + icalSubscriptions: s.icalSubscriptions, + createEvent: s.createEvent, + updateEvent: s.updateEvent, + deleteEvent: s.deleteEvent, + setSelectedDate: s.setSelectedDate, + isSubscriptionCalendar: s.isSubscriptionCalendar, + }))); + + const [lookup, setLookup] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [refresh, setRefresh] = useState(0); + + const senderDomain = senderDomainOf(email?.from?.[0]?.email); + const subject = email?.subject; + const labels = useMemo>(() => ({ + reference: (reference) => t('description.reference', { reference }), + passengers: (names) => t('description.passengers', { names }), + seats: (seats) => t('description.seats', { seats }), + checkin: (url) => t('description.checkin', { url }), + manage: (url) => t('description.manage', { url }), + ticket: (url) => t('description.ticket', { url }), + phone: (phone) => t('description.phone', { phone }), + car: (car) => t('description.car', { car }), + tickets: (numbers) => t('description.tickets', { numbers }), + partySize: (count) => t('description.party_size', { count }), + rentalCar: (company) => (company ? t('title.rental_car_company', { company }) : t('title.rental_car')), + addedFrom: (domain, mailSubject) => (mailSubject + ? t('description.added_from_subject', { domain, subject: mailSubject }) + : t('description.added_from', { 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 = Boolean(client) && supportsCalendar && calendarEnabled && !over && slots.length > 0; + + useEffect(() => { + if (!active || !client || !uidKey) return; + let current = true; + const uids = uidKey.split(' '); + // Without targetAccountId this searches only the primary calendar + // account, the only one the picker offers. + Promise.all(uids.map((uid) => client.queryCalendarEvents({ uid }))) + .then((results) => { + if (!current) return; + const events: Record = {}; + uids.forEach((uid, i) => { + events[uid] = results[i].find((e) => e.uid === uid && !e.recurrenceId) ?? null; + }); + 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, client, uidKey, refresh]); + + const targets = useMemo( + () => bookingTargetCalendars(calendars, isSubscriptionCalendar, activeAccountId), + // icalSubscriptions changes what isSubscriptionCalendar answers. + [calendars, isSubscriptionCalendar, activeAccountId, icalSubscriptions], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const known = lookup && lookup.key === uidKey && !lookup.failed ? lookup.events : null; + const existingOf = useCallback((uid: string): CalendarEvent | null => { + const stored = known?.[uid]; + if (!stored) return null; + // The store's copy of the same event is fresher after a change elsewhere. + return storeEvents.find((e) => e.id === stored.id) ?? stored; + }, [known, storeEvents]); + + const plan = useMemo(() => { + 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, work: () => Promise) => { + if (!client || busy) return; + setBusy(true); + setError(null); + setNotice(null); + try { + if (await work()) setNotice(done); + } catch (err) { + setError(describeError(err, t('action_failed'))); + setRefresh((n) => n + 1); + } finally { + setBusy(false); + } + }, [client, busy, describeError, t]); + + const createMissing = useCallback(async (calendarId: string): Promise => { + if (!client) return false; + for (const { slot, existing } of plan.live) { + if (existing) continue; + const created = await createEvent(client, { ...slot.event, calendarIds: { [calendarId]: true } }); + // null: refused (a plugin, or the server, e.g. the uid exists after all). + if (!created) throw new Error('create failed'); + remember(slot.uid, created); + } + return true; + }, [client, plan.live, createEvent, remember]); + + const removeStale = useCallback(async (): Promise => { + if (!client) return false; + for (const { slot, existing } of plan.stale) { + if (!(await deleteEvent(client, existing.id))) return false; + remember(slot.uid, null); + } + return true; + }, [client, plan.stale, deleteEvent, remember]); + + const add = useCallback((calendarId?: string) => run('added', async () => { + const target = calendarId ?? targets[0]?.id; + if (!target || !targets.some((c) => c.id === target)) throw new Error('no writable calendar'); + return createMissing(target); + }), [run, targets, createMissing]); + + const update = useCallback(() => run('updated', async () => { + if (!client) return false; + for (const { slot, existing } of plan.live) { + if (!existing || !eventNeedsUpdate(existing, slot.event as Partial)) continue; + const patch = bookingEventPatch(existing, slot.event as Partial); + if (!(await updateEvent(client, existing.id, patch))) return false; + const updated = { ...existing, ...patch } as CalendarEvent; + remember(slot.uid, updated); + // The card reads the store's copy first; make sure it holds the change. + useCalendarStore.setState((s) => ({ events: s.events.map((e) => (e.id === existing.id ? { ...e, ...patch } : e)) })); + } + 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'); + if (!(await createMissing(target))) return false; + } + return removeStale(); + }), [run, client, plan.live, 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?.start) { + const date = getEventStartDate({ + start: target.start, + utcStart: target.utcStart ?? null, + showWithoutTime: target.showWithoutTime ?? false, + timeZone: target.timeZone, + }); + if (!Number.isNaN(date.getTime())) setSelectedDate(date); + } + router.push('/calendar'); + }, [plan, setSelectedDate, router]); + + return { + status, + calendars: targets, + busy, + error: status === 'hidden' ? null : error, + notice: status === 'hidden' ? null : notice, + add, + update, + remove, + viewInCalendar, + }; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5011d5623..f8a91e8b7 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5951,5 +5951,35 @@ "easy_read": "قراءة سهلة", "sign_language": "لغة الإشارة", "new_tab": "يُفتح في علامة تبويب جديدة" + }, + "structured_mail_calendar": { + "add_to_calendar": "إضافة إلى التقويم", + "update_in_calendar": "تحديث في التقويم", + "remove_from_calendar": "إزالة من التقويم", + "in_calendar": "في تقويمك", + "view_in_calendar": "عرض في التقويم", + "select_calendar": "اختر تقويمًا", + "added": "تمت الإضافة إلى تقويمك", + "updated": "تم تحديث التقويم", + "removed": "تمت الإزالة من تقويمك", + "action_failed": "تعذّر تغيير تقويمك. حاول مرة أخرى.", + "title": { + "rental_car": "سيارة مستأجرة", + "rental_car_company": "سيارة مستأجرة: {company}" + }, + "description": { + "reference": "رقم الحجز: {reference}", + "passengers": "المسافرون: {names}", + "seats": "المقاعد: {seats}", + "checkin": "تسجيل الوصول: {url}", + "manage": "إدارة الحجز: {url}", + "ticket": "التذكرة: {url}", + "phone": "الهاتف: {phone}", + "car": "السيارة: {car}", + "tickets": "التذاكر: {numbers}", + "party_size": "عدد الأشخاص: {count}", + "added_from": "أُضيف من رسالة من {domain}", + "added_from_subject": "أُضيف من الرسالة «{subject}» من {domain}" + } } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 5fd11fc39..06f73178c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Lectura fàcil", "sign_language": "Llengua de signes", "new_tab": "s'obre en una pestanya nova" + }, + "structured_mail_calendar": { + "add_to_calendar": "Afegeix al calendari", + "update_in_calendar": "Actualitza al calendari", + "remove_from_calendar": "Treu del calendari", + "in_calendar": "Al vostre calendari", + "view_in_calendar": "Mostra al calendari", + "select_calendar": "Trieu un calendari", + "added": "S’ha afegit al vostre calendari", + "updated": "S’ha actualitzat el calendari", + "removed": "S’ha tret del vostre calendari", + "action_failed": "No s’ha pogut modificar el calendari. Torneu-ho a provar.", + "title": { + "rental_car": "Cotxe de lloguer", + "rental_car_company": "Cotxe de lloguer: {company}" + }, + "description": { + "reference": "Codi de reserva: {reference}", + "passengers": "Passatgers: {names}", + "seats": "Seients: {seats}", + "checkin": "Facturació: {url}", + "manage": "Gestiona la reserva: {url}", + "ticket": "Bitllet: {url}", + "phone": "Telèfon: {phone}", + "car": "Cotxe: {car}", + "tickets": "Entrades: {numbers}", + "party_size": "Persones: {count}", + "added_from": "Afegit des d’un correu de {domain}", + "added_from_subject": "Afegit des del correu «{subject}» de {domain}" + } } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 4312bdf79..d64744cf2 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Snadné čtení", "sign_language": "Znakový jazyk", "new_tab": "otevře se na nové kartě" + }, + "structured_mail_calendar": { + "add_to_calendar": "Přidat do kalendáře", + "update_in_calendar": "Aktualizovat v kalendáři", + "remove_from_calendar": "Odebrat z kalendáře", + "in_calendar": "Ve vašem kalendáři", + "view_in_calendar": "Zobrazit v kalendáři", + "select_calendar": "Vyberte kalendář", + "added": "Přidáno do vašeho kalendáře", + "updated": "Kalendář aktualizován", + "removed": "Odebráno z vašeho kalendáře", + "action_failed": "Kalendář se nepodařilo změnit. Zkuste to znovu.", + "title": { + "rental_car": "Pronájem auta", + "rental_car_company": "Pronájem auta: {company}" + }, + "description": { + "reference": "Číslo rezervace: {reference}", + "passengers": "Cestující: {names}", + "seats": "Místa: {seats}", + "checkin": "Odbavení: {url}", + "manage": "Spravovat rezervaci: {url}", + "ticket": "Lístek: {url}", + "phone": "Telefon: {phone}", + "car": "Vůz: {car}", + "tickets": "Vstupenky: {numbers}", + "party_size": "Počet osob: {count}", + "added_from": "Přidáno z e-mailu od {domain}", + "added_from_subject": "Přidáno z e-mailu „{subject}“ od {domain}" + } } } diff --git a/locales/da/common.json b/locales/da/common.json index d2a51aabe..5abf5b6f5 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Let læst", "sign_language": "Tegnsprog", "new_tab": "åbner i en ny fane" + }, + "structured_mail_calendar": { + "add_to_calendar": "Tilføj til kalender", + "update_in_calendar": "Opdater i kalender", + "remove_from_calendar": "Fjern fra kalender", + "in_calendar": "I din kalender", + "view_in_calendar": "Vis i kalender", + "select_calendar": "Vælg en kalender", + "added": "Tilføjet til din kalender", + "updated": "Kalender opdateret", + "removed": "Fjernet fra din kalender", + "action_failed": "Din kalender kunne ikke ændres. Prøv igen.", + "title": { + "rental_car": "Lejebil", + "rental_car_company": "Lejebil: {company}" + }, + "description": { + "reference": "Bookingnummer: {reference}", + "passengers": "Rejsende: {names}", + "seats": "Pladser: {seats}", + "checkin": "Check-in: {url}", + "manage": "Administrer booking: {url}", + "ticket": "Billet: {url}", + "phone": "Telefon: {phone}", + "car": "Bil: {car}", + "tickets": "Billetter: {numbers}", + "party_size": "Antal personer: {count}", + "added_from": "Tilføjet fra en mail fra {domain}", + "added_from_subject": "Tilføjet fra mailen »{subject}« fra {domain}" + } } } diff --git a/locales/de/common.json b/locales/de/common.json index 3d0b12f25..6572b214e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Leichte Sprache", "sign_language": "Gebärdensprache", "new_tab": "öffnet in einem neuen Tab" + }, + "structured_mail_calendar": { + "add_to_calendar": "Zum Kalender hinzufügen", + "update_in_calendar": "Im Kalender aktualisieren", + "remove_from_calendar": "Aus Kalender entfernen", + "in_calendar": "In Ihrem Kalender", + "view_in_calendar": "Im Kalender anzeigen", + "select_calendar": "Kalender auswählen", + "added": "Zu Ihrem Kalender hinzugefügt", + "updated": "Kalender aktualisiert", + "removed": "Aus Ihrem Kalender entfernt", + "action_failed": "Ihr Kalender konnte nicht geändert werden. Bitte versuchen Sie es erneut.", + "title": { + "rental_car": "Mietwagen", + "rental_car_company": "Mietwagen: {company}" + }, + "description": { + "reference": "Buchungsnummer: {reference}", + "passengers": "Reisende: {names}", + "seats": "Sitzplätze: {seats}", + "checkin": "Check-in: {url}", + "manage": "Buchung verwalten: {url}", + "ticket": "Ticket: {url}", + "phone": "Telefon: {phone}", + "car": "Fahrzeug: {car}", + "tickets": "Tickets: {numbers}", + "party_size": "Personen: {count}", + "added_from": "Hinzugefügt aus einer E-Mail von {domain}", + "added_from_subject": "Hinzugefügt aus der E-Mail „{subject}“ von {domain}" + } } } diff --git a/locales/en/common.json b/locales/en/common.json index ac2cef4e6..f6e512ea1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Easy Read", "sign_language": "Sign language", "new_tab": "opens in a new tab" + }, + "structured_mail_calendar": { + "add_to_calendar": "Add to calendar", + "update_in_calendar": "Update in calendar", + "remove_from_calendar": "Remove from calendar", + "in_calendar": "In your calendar", + "view_in_calendar": "View in calendar", + "select_calendar": "Choose a calendar", + "added": "Added to your calendar", + "updated": "Calendar updated", + "removed": "Removed from your calendar", + "action_failed": "Couldn't change your calendar. Try again.", + "title": { + "rental_car": "Rental car", + "rental_car_company": "Rental car: {company}" + }, + "description": { + "reference": "Booking reference: {reference}", + "passengers": "Passengers: {names}", + "seats": "Seats: {seats}", + "checkin": "Check in: {url}", + "manage": "Manage booking: {url}", + "ticket": "Ticket: {url}", + "phone": "Phone: {phone}", + "car": "Car: {car}", + "tickets": "Tickets: {numbers}", + "party_size": "Party size: {count}", + "added_from": "Added from a mail from {domain}", + "added_from_subject": "Added from the mail “{subject}” from {domain}" + } } } diff --git a/locales/es/common.json b/locales/es/common.json index d86594ef8..f7cc461b1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Lectura fácil", "sign_language": "Lengua de signos", "new_tab": "se abre en una pestaña nueva" + }, + "structured_mail_calendar": { + "add_to_calendar": "Añadir al calendario", + "update_in_calendar": "Actualizar en el calendario", + "remove_from_calendar": "Quitar del calendario", + "in_calendar": "En tu calendario", + "view_in_calendar": "Ver en el calendario", + "select_calendar": "Elige un calendario", + "added": "Añadido a tu calendario", + "updated": "Calendario actualizado", + "removed": "Quitado de tu calendario", + "action_failed": "No se pudo cambiar tu calendario. Inténtalo de nuevo.", + "title": { + "rental_car": "Coche de alquiler", + "rental_car_company": "Coche de alquiler: {company}" + }, + "description": { + "reference": "Código de reserva: {reference}", + "passengers": "Pasajeros: {names}", + "seats": "Asientos: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gestionar la reserva: {url}", + "ticket": "Billete o entrada: {url}", + "phone": "Teléfono: {phone}", + "car": "Vehículo: {car}", + "tickets": "Entradas: {numbers}", + "party_size": "Personas: {count}", + "added_from": "Añadido desde un correo de {domain}", + "added_from_subject": "Añadido desde el correo «{subject}» de {domain}" + } } } diff --git a/locales/et/common.json b/locales/et/common.json index 80c687eb0..f14c71c20 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Lihtne keel", "sign_language": "Viipekeel", "new_tab": "avaneb uuel vahekaardil" + }, + "structured_mail_calendar": { + "add_to_calendar": "Lisa kalendrisse", + "update_in_calendar": "Uuenda kalendris", + "remove_from_calendar": "Eemalda kalendrist", + "in_calendar": "Sinu kalendris", + "view_in_calendar": "Vaata kalendris", + "select_calendar": "Vali kalender", + "added": "Lisatud sinu kalendrisse", + "updated": "Kalender uuendatud", + "removed": "Eemaldatud sinu kalendrist", + "action_failed": "Kalendrit ei õnnestunud muuta. Proovi uuesti.", + "title": { + "rental_car": "Rendiauto", + "rental_car_company": "Rendiauto: {company}" + }, + "description": { + "reference": "Broneeringu number: {reference}", + "passengers": "Reisijad: {names}", + "seats": "Kohad: {seats}", + "checkin": "Registreerimine: {url}", + "manage": "Halda broneeringut: {url}", + "ticket": "Pilet: {url}", + "phone": "Telefon: {phone}", + "car": "Auto: {car}", + "tickets": "Piletid: {numbers}", + "party_size": "Inimesi: {count}", + "added_from": "Lisatud kirjast, mille saatis {domain}", + "added_from_subject": "Lisatud kirjast „{subject}“, mille saatis {domain}" + } } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 9952d95f1..ba90429d4 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5951,5 +5951,35 @@ "easy_read": "متن آسان‌خوان", "sign_language": "زبان اشاره", "new_tab": "در زبانه‌ای تازه باز می‌شود" + }, + "structured_mail_calendar": { + "add_to_calendar": "افزودن به تقویم", + "update_in_calendar": "به‌روزرسانی در تقویم", + "remove_from_calendar": "حذف از تقویم", + "in_calendar": "در تقویم شما", + "view_in_calendar": "مشاهده در تقویم", + "select_calendar": "یک تقویم انتخاب کنید", + "added": "به تقویم شما اضافه شد", + "updated": "تقویم به‌روزرسانی شد", + "removed": "از تقویم شما حذف شد", + "action_failed": "تغییر تقویم انجام نشد. دوباره تلاش کنید.", + "title": { + "rental_car": "خودروی اجاره‌ای", + "rental_car_company": "خودروی اجاره‌ای: {company}" + }, + "description": { + "reference": "کد رزرو: {reference}", + "passengers": "مسافران: {names}", + "seats": "صندلی‌ها: {seats}", + "checkin": "پذیرش آنلاین: {url}", + "manage": "مدیریت رزرو: {url}", + "ticket": "بلیت: {url}", + "phone": "تلفن: {phone}", + "car": "خودرو: {car}", + "tickets": "بلیت‌ها: {numbers}", + "party_size": "تعداد نفرات: {count}", + "added_from": "از ایمیلی از {domain} اضافه شد", + "added_from_subject": "از ایمیل «{subject}» از {domain} اضافه شد" + } } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 0b7c3ca2b..91b97baad 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Facile à lire", "sign_language": "Langue des signes", "new_tab": "s'ouvre dans un nouvel onglet" + }, + "structured_mail_calendar": { + "add_to_calendar": "Ajouter au calendrier", + "update_in_calendar": "Mettre à jour dans le calendrier", + "remove_from_calendar": "Retirer du calendrier", + "in_calendar": "Dans votre calendrier", + "view_in_calendar": "Voir dans le calendrier", + "select_calendar": "Choisir un calendrier", + "added": "Ajouté à votre calendrier", + "updated": "Calendrier mis à jour", + "removed": "Retiré de votre calendrier", + "action_failed": "Impossible de modifier votre calendrier. Réessayez.", + "title": { + "rental_car": "Voiture de location", + "rental_car_company": "Voiture de location : {company}" + }, + "description": { + "reference": "Référence de réservation : {reference}", + "passengers": "Passagers : {names}", + "seats": "Sièges : {seats}", + "checkin": "Enregistrement : {url}", + "manage": "Gérer la réservation : {url}", + "ticket": "Billet : {url}", + "phone": "Téléphone : {phone}", + "car": "Véhicule : {car}", + "tickets": "Billets : {numbers}", + "party_size": "Nombre de personnes : {count}", + "added_from": "Ajouté depuis un e-mail de {domain}", + "added_from_subject": "Ajouté depuis l’e-mail « {subject} » de {domain}" + } } } diff --git a/locales/he/common.json b/locales/he/common.json index a2e191de9..f2ec6680f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5952,5 +5952,35 @@ "easy_read": "שפה פשוטה", "sign_language": "שפת סימנים", "new_tab": "נפתח בכרטיסייה חדשה" + }, + "structured_mail_calendar": { + "add_to_calendar": "הוסף ליומן", + "update_in_calendar": "עדכן ביומן", + "remove_from_calendar": "הסרה מהיומן", + "in_calendar": "ביומן שלך", + "view_in_calendar": "הצג בלוח שנה", + "select_calendar": "בחר יומן", + "added": "נוסף ליומן שלך", + "updated": "היומן עודכן", + "removed": "הוסר מהיומן שלך", + "action_failed": "לא ניתן היה לשנות את היומן. נסה שוב.", + "title": { + "rental_car": "רכב שכור", + "rental_car_company": "רכב שכור: {company}" + }, + "description": { + "reference": "מספר הזמנה: {reference}", + "passengers": "נוסעים: {names}", + "seats": "מושבים: {seats}", + "checkin": "צ׳ק-אין: {url}", + "manage": "ניהול ההזמנה: {url}", + "ticket": "כרטיס: {url}", + "phone": "טלפון: {phone}", + "car": "רכב: {car}", + "tickets": "כרטיסים: {numbers}", + "party_size": "מספר אנשים: {count}", + "added_from": "נוסף מהודעה מ-{domain}", + "added_from_subject": "נוסף מההודעה ״{subject}״ מ-{domain}" + } } } diff --git a/locales/hu/common.json b/locales/hu/common.json index dbe28e232..66941bfb6 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Könnyen érthető", "sign_language": "Jelnyelv", "new_tab": "új lapon nyílik meg" + }, + "structured_mail_calendar": { + "add_to_calendar": "Hozzáadás a naptárhoz", + "update_in_calendar": "Frissítés a naptárban", + "remove_from_calendar": "Eltávolítás a naptárból", + "in_calendar": "A naptáradban", + "view_in_calendar": "Megtekintés a naptárban", + "select_calendar": "Válassz naptárat", + "added": "Hozzáadva a naptáradhoz", + "updated": "Naptár frissítve", + "removed": "Eltávolítva a naptáradból", + "action_failed": "Nem sikerült módosítani a naptárat. Próbáld újra.", + "title": { + "rental_car": "Bérautó", + "rental_car_company": "Bérautó: {company}" + }, + "description": { + "reference": "Foglalási szám: {reference}", + "passengers": "Utasok: {names}", + "seats": "Ülőhelyek: {seats}", + "checkin": "Utasfelvétel: {url}", + "manage": "Foglalás kezelése: {url}", + "ticket": "Jegy: {url}", + "phone": "Telefon: {phone}", + "car": "Autó: {car}", + "tickets": "Jegyek: {numbers}", + "party_size": "Létszám: {count}", + "added_from": "Hozzáadva egy {domain} által küldött levélből", + "added_from_subject": "Hozzáadva a(z) {domain} által küldött „{subject}” levélből" + } } } diff --git a/locales/it/common.json b/locales/it/common.json index 7a4063840..12ddd4328 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Facile da leggere", "sign_language": "Lingua dei segni", "new_tab": "si apre in una nuova scheda" + }, + "structured_mail_calendar": { + "add_to_calendar": "Aggiungi al calendario", + "update_in_calendar": "Aggiorna nel calendario", + "remove_from_calendar": "Rimuovi dal calendario", + "in_calendar": "Nel tuo calendario", + "view_in_calendar": "Vedi nel calendario", + "select_calendar": "Scegli un calendario", + "added": "Aggiunto al tuo calendario", + "updated": "Calendario aggiornato", + "removed": "Rimosso dal tuo calendario", + "action_failed": "Impossibile modificare il calendario. Riprova.", + "title": { + "rental_car": "Auto a noleggio", + "rental_car_company": "Auto a noleggio: {company}" + }, + "description": { + "reference": "Codice di prenotazione: {reference}", + "passengers": "Passeggeri: {names}", + "seats": "Posti: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gestisci la prenotazione: {url}", + "ticket": "Biglietto: {url}", + "phone": "Telefono: {phone}", + "car": "Auto: {car}", + "tickets": "Biglietti: {numbers}", + "party_size": "Persone: {count}", + "added_from": "Aggiunto da un’e-mail di {domain}", + "added_from_subject": "Aggiunto dall’e-mail «{subject}» di {domain}" + } } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 8eff3d2ce..5f8b9f2df 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5951,5 +5951,35 @@ "easy_read": "わかりやすい版", "sign_language": "手話", "new_tab": "新しいタブで開きます" + }, + "structured_mail_calendar": { + "add_to_calendar": "カレンダーに追加", + "update_in_calendar": "カレンダーを更新", + "remove_from_calendar": "カレンダーから削除", + "in_calendar": "カレンダーに登録済み", + "view_in_calendar": "カレンダーで表示", + "select_calendar": "カレンダーを選択", + "added": "カレンダーに追加しました", + "updated": "カレンダーを更新しました", + "removed": "カレンダーから削除しました", + "action_failed": "カレンダーを変更できませんでした。もう一度お試しください。", + "title": { + "rental_car": "レンタカー", + "rental_car_company": "レンタカー: {company}" + }, + "description": { + "reference": "予約番号: {reference}", + "passengers": "乗客: {names}", + "seats": "座席: {seats}", + "checkin": "チェックイン: {url}", + "manage": "予約の管理: {url}", + "ticket": "チケット: {url}", + "phone": "電話: {phone}", + "car": "車両: {car}", + "tickets": "チケット番号: {numbers}", + "party_size": "人数: {count}", + "added_from": "{domain} からのメールから追加", + "added_from_subject": "{domain} からのメール「{subject}」から追加" + } } } diff --git a/locales/ko/common.json b/locales/ko/common.json index c39ef6888..a4b9e68e1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5951,5 +5951,35 @@ "easy_read": "쉬운 글", "sign_language": "수어", "new_tab": "새 탭에서 열림" + }, + "structured_mail_calendar": { + "add_to_calendar": "캘린더에 추가", + "update_in_calendar": "캘린더에서 업데이트", + "remove_from_calendar": "캘린더에서 제거", + "in_calendar": "내 캘린더에 있어요", + "view_in_calendar": "캘린더에서 보기", + "select_calendar": "캘린더 선택", + "added": "캘린더에 추가되었어요", + "updated": "캘린더가 업데이트되었어요", + "removed": "캘린더에서 제거되었어요", + "action_failed": "캘린더를 변경할 수 없어요. 다시 시도해 주세요.", + "title": { + "rental_car": "렌터카", + "rental_car_company": "렌터카: {company}" + }, + "description": { + "reference": "예약 번호: {reference}", + "passengers": "탑승객: {names}", + "seats": "좌석: {seats}", + "checkin": "체크인: {url}", + "manage": "예약 관리: {url}", + "ticket": "티켓: {url}", + "phone": "전화: {phone}", + "car": "차량: {car}", + "tickets": "티켓 번호: {numbers}", + "party_size": "인원: {count}", + "added_from": "{domain}에서 보낸 메일에서 추가함", + "added_from_subject": "{domain}에서 보낸 메일 “{subject}”에서 추가함" + } } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 3e306355d..49676a909 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Vieglā valoda", "sign_language": "Zīmju valoda", "new_tab": "atveras jaunā cilnē" + }, + "structured_mail_calendar": { + "add_to_calendar": "Pievienot kalendāram", + "update_in_calendar": "Atjaunināt kalendārā", + "remove_from_calendar": "Noņemt no kalendāra", + "in_calendar": "Jūsu kalendārā", + "view_in_calendar": "Atvērt kalendārā", + "select_calendar": "Izvēlieties kalendāru", + "added": "Pievienots jūsu kalendāram", + "updated": "Kalendārs atjaunināts", + "removed": "Noņemts no jūsu kalendāra", + "action_failed": "Neizdevās mainīt kalendāru. Mēģiniet vēlreiz.", + "title": { + "rental_car": "Nomas auto", + "rental_car_company": "Nomas auto: {company}" + }, + "description": { + "reference": "Rezervācijas numurs: {reference}", + "passengers": "Pasažieri: {names}", + "seats": "Vietas: {seats}", + "checkin": "Reģistrēšanās: {url}", + "manage": "Pārvaldīt rezervāciju: {url}", + "ticket": "Biļete: {url}", + "phone": "Tālrunis: {phone}", + "car": "Automašīna: {car}", + "tickets": "Biļetes: {numbers}", + "party_size": "Personu skaits: {count}", + "added_from": "Pievienots no {domain} e-pasta", + "added_from_subject": "Pievienots no {domain} e-pasta “{subject}”" + } } } diff --git a/locales/mn/common.json b/locales/mn/common.json index a4b8666a7..765452785 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Хялбар унших", "sign_language": "Дохионы хэл", "new_tab": "шинэ табад нээгдэнэ" + }, + "structured_mail_calendar": { + "add_to_calendar": "Хуанлид нэмэх", + "update_in_calendar": "Хуанлид шинэчлэх", + "remove_from_calendar": "Хуанлиас хасах", + "in_calendar": "Таны хуанлид байна", + "view_in_calendar": "Хуанлиар харах", + "select_calendar": "Хуанли сонгох", + "added": "Таны хуанлид нэмсэн", + "updated": "Хуанли шинэчлэгдлээ", + "removed": "Таны хуанлиас хассан", + "action_failed": "Хуанлийг өөрчилж чадсангүй. Дахин оролдоно уу.", + "title": { + "rental_car": "Түрээсийн машин", + "rental_car_company": "Түрээсийн машин: {company}" + }, + "description": { + "reference": "Захиалгын дугаар: {reference}", + "passengers": "Зорчигчид: {names}", + "seats": "Суудал: {seats}", + "checkin": "Бүртгэл: {url}", + "manage": "Захиалга удирдах: {url}", + "ticket": "Тасалбар: {url}", + "phone": "Утас: {phone}", + "car": "Машин: {car}", + "tickets": "Тасалбарууд: {numbers}", + "party_size": "Хүний тоо: {count}", + "added_from": "{domain}-аас ирсэн имэйлээс нэмсэн", + "added_from_subject": "{domain}-аас ирсэн “{subject}” имэйлээс нэмсэн" + } } } diff --git a/locales/nb/common.json b/locales/nb/common.json index f205d3a22..15cba65f7 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Lettlest", "sign_language": "Tegnspråk", "new_tab": "åpnes i en ny fane" + }, + "structured_mail_calendar": { + "add_to_calendar": "Legg til i kalenderen", + "update_in_calendar": "Oppdater i kalenderen", + "remove_from_calendar": "Fjern fra kalenderen", + "in_calendar": "I kalenderen din", + "view_in_calendar": "Se i kalenderen", + "select_calendar": "Velg en kalender", + "added": "Lagt til i kalenderen din", + "updated": "Kalenderen er oppdatert", + "removed": "Fjernet fra kalenderen din", + "action_failed": "Kunne ikke endre kalenderen. Prøv igjen.", + "title": { + "rental_car": "Leiebil", + "rental_car_company": "Leiebil: {company}" + }, + "description": { + "reference": "Bestillingsnummer: {reference}", + "passengers": "Reisende: {names}", + "seats": "Seter: {seats}", + "checkin": "Innsjekking: {url}", + "manage": "Administrer bestillingen: {url}", + "ticket": "Billett: {url}", + "phone": "Telefon: {phone}", + "car": "Bil: {car}", + "tickets": "Billetter: {numbers}", + "party_size": "Antall personer: {count}", + "added_from": "Lagt til fra en e-post fra {domain}", + "added_from_subject": "Lagt til fra e-posten «{subject}» fra {domain}" + } } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 83bb922d8..68b4f213b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Makkelijk lezen", "sign_language": "Gebarentaal", "new_tab": "opent in een nieuw tabblad" + }, + "structured_mail_calendar": { + "add_to_calendar": "Toevoegen aan agenda", + "update_in_calendar": "Bijwerken in agenda", + "remove_from_calendar": "Uit agenda verwijderen", + "in_calendar": "In je agenda", + "view_in_calendar": "In agenda bekijken", + "select_calendar": "Kies een agenda", + "added": "Toegevoegd aan je agenda", + "updated": "Agenda bijgewerkt", + "removed": "Uit je agenda verwijderd", + "action_failed": "Je agenda kon niet worden gewijzigd. Probeer het opnieuw.", + "title": { + "rental_car": "Huurauto", + "rental_car_company": "Huurauto: {company}" + }, + "description": { + "reference": "Boekingsnummer: {reference}", + "passengers": "Reizigers: {names}", + "seats": "Stoelen: {seats}", + "checkin": "Inchecken: {url}", + "manage": "Boeking beheren: {url}", + "ticket": "Ticket: {url}", + "phone": "Telefoon: {phone}", + "car": "Auto: {car}", + "tickets": "Tickets: {numbers}", + "party_size": "Aantal personen: {count}", + "added_from": "Toegevoegd vanuit een e-mail van {domain}", + "added_from_subject": "Toegevoegd vanuit de e-mail ‘{subject}’ van {domain}" + } } } diff --git a/locales/pl/common.json b/locales/pl/common.json index ca617443d..6144b0b4c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Tekst łatwy do czytania", "sign_language": "Język migowy", "new_tab": "otwiera się w nowej karcie" + }, + "structured_mail_calendar": { + "add_to_calendar": "Dodaj do kalendarza", + "update_in_calendar": "Zaktualizuj w kalendarzu", + "remove_from_calendar": "Usuń z kalendarza", + "in_calendar": "W Twoim kalendarzu", + "view_in_calendar": "Pokaż w kalendarzu", + "select_calendar": "Wybierz kalendarz", + "added": "Dodano do Twojego kalendarza", + "updated": "Zaktualizowano kalendarz", + "removed": "Usunięto z Twojego kalendarza", + "action_failed": "Nie udało się zmienić kalendarza. Spróbuj ponownie.", + "title": { + "rental_car": "Wynajem samochodu", + "rental_car_company": "Wynajem samochodu: {company}" + }, + "description": { + "reference": "Numer rezerwacji: {reference}", + "passengers": "Pasażerowie: {names}", + "seats": "Miejsca: {seats}", + "checkin": "Odprawa: {url}", + "manage": "Zarządzaj rezerwacją: {url}", + "ticket": "Bilet: {url}", + "phone": "Telefon: {phone}", + "car": "Samochód: {car}", + "tickets": "Bilety: {numbers}", + "party_size": "Liczba osób: {count}", + "added_from": "Dodano z wiadomości od {domain}", + "added_from_subject": "Dodano z wiadomości „{subject}” od {domain}" + } } } diff --git a/locales/pt/common.json b/locales/pt/common.json index bdbf61dc3..129db2d24 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Leitura fácil", "sign_language": "Língua de sinais", "new_tab": "abre em uma nova aba" + }, + "structured_mail_calendar": { + "add_to_calendar": "Adicionar ao calendário", + "update_in_calendar": "Atualizar no calendário", + "remove_from_calendar": "Remover do calendário", + "in_calendar": "No seu calendário", + "view_in_calendar": "Ver no calendário", + "select_calendar": "Escolha um calendário", + "added": "Adicionado ao seu calendário", + "updated": "Calendário atualizado", + "removed": "Removido do seu calendário", + "action_failed": "Não foi possível alterar seu calendário. Tente novamente.", + "title": { + "rental_car": "Carro alugado", + "rental_car_company": "Carro alugado: {company}" + }, + "description": { + "reference": "Código da reserva: {reference}", + "passengers": "Passageiros: {names}", + "seats": "Assentos: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gerenciar reserva: {url}", + "ticket": "Bilhete: {url}", + "phone": "Telefone: {phone}", + "car": "Carro: {car}", + "tickets": "Ingressos: {numbers}", + "party_size": "Pessoas: {count}", + "added_from": "Adicionado a partir de um e-mail de {domain}", + "added_from_subject": "Adicionado a partir do e-mail “{subject}” de {domain}" + } } } diff --git a/locales/ro/common.json b/locales/ro/common.json index e037c4e25..942f61bf2 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Ușor de citit", "sign_language": "Limbaj mimico-gestual", "new_tab": "se deschide într-o filă nouă" + }, + "structured_mail_calendar": { + "add_to_calendar": "Adaugă în calendar", + "update_in_calendar": "Actualizează în calendar", + "remove_from_calendar": "Elimină din calendar", + "in_calendar": "În calendarul dvs.", + "view_in_calendar": "Vizualizare în calendar", + "select_calendar": "Alegeți un calendar", + "added": "Adăugat în calendarul dvs.", + "updated": "Calendar actualizat", + "removed": "Eliminat din calendarul dvs.", + "action_failed": "Calendarul nu a putut fi modificat. Încercați din nou.", + "title": { + "rental_car": "Mașină închiriată", + "rental_car_company": "Mașină închiriată: {company}" + }, + "description": { + "reference": "Cod rezervare: {reference}", + "passengers": "Pasageri: {names}", + "seats": "Locuri: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gestionați rezervarea: {url}", + "ticket": "Bilet: {url}", + "phone": "Telefon: {phone}", + "car": "Mașină: {car}", + "tickets": "Bilete: {numbers}", + "party_size": "Număr de persoane: {count}", + "added_from": "Adăugat dintr-un e-mail de la {domain}", + "added_from_subject": "Adăugat din e-mailul „{subject}” de la {domain}" + } } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 358e3a6f6..49f2f69dc 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Простой язык", "sign_language": "Жестовый язык", "new_tab": "открывается в новой вкладке" + }, + "structured_mail_calendar": { + "add_to_calendar": "Добавить в календарь", + "update_in_calendar": "Обновить в календаре", + "remove_from_calendar": "Удалить из календаря", + "in_calendar": "В вашем календаре", + "view_in_calendar": "Открыть в календаре", + "select_calendar": "Выберите календарь", + "added": "Добавлено в ваш календарь", + "updated": "Календарь обновлён", + "removed": "Удалено из вашего календаря", + "action_failed": "Не удалось изменить календарь. Попробуйте ещё раз.", + "title": { + "rental_car": "Аренда автомобиля", + "rental_car_company": "Аренда автомобиля: {company}" + }, + "description": { + "reference": "Номер бронирования: {reference}", + "passengers": "Пассажиры: {names}", + "seats": "Места: {seats}", + "checkin": "Регистрация: {url}", + "manage": "Управление бронированием: {url}", + "ticket": "Билет: {url}", + "phone": "Телефон: {phone}", + "car": "Автомобиль: {car}", + "tickets": "Билеты: {numbers}", + "party_size": "Количество человек: {count}", + "added_from": "Добавлено из письма от {domain}", + "added_from_subject": "Добавлено из письма «{subject}» от {domain}" + } } } diff --git a/locales/sk/common.json b/locales/sk/common.json index f8b7d5eb5..1a89c85fd 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Ľahké čítanie", "sign_language": "Posunkový jazyk", "new_tab": "otvorí sa na novej karte" + }, + "structured_mail_calendar": { + "add_to_calendar": "Pridať do kalendára", + "update_in_calendar": "Aktualizovať v kalendári", + "remove_from_calendar": "Odstrániť z kalendára", + "in_calendar": "Vo vašom kalendári", + "view_in_calendar": "Zobraziť v kalendári", + "select_calendar": "Vyberte kalendár", + "added": "Pridané do vášho kalendára", + "updated": "Kalendár bol aktualizovaný", + "removed": "Odstránené z vášho kalendára", + "action_failed": "Kalendár sa nepodarilo zmeniť. Skúste to znova.", + "title": { + "rental_car": "Prenájom auta", + "rental_car_company": "Prenájom auta: {company}" + }, + "description": { + "reference": "Číslo rezervácie: {reference}", + "passengers": "Cestujúci: {names}", + "seats": "Miesta: {seats}", + "checkin": "Odbavenie: {url}", + "manage": "Spravovať rezerváciu: {url}", + "ticket": "Lístok: {url}", + "phone": "Telefón: {phone}", + "car": "Vozidlo: {car}", + "tickets": "Vstupenky: {numbers}", + "party_size": "Počet osôb: {count}", + "added_from": "Pridané z e-mailu od {domain}", + "added_from_subject": "Pridané z e-mailu „{subject}“ od {domain}" + } } } diff --git a/locales/th/common.json b/locales/th/common.json index 59ecfb5c1..e1fc28df3 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -5959,5 +5959,35 @@ "easy_read": "ภาษาที่อ่านง่าย", "sign_language": "ภาษามือ", "new_tab": "เปิดในแท็บใหม่" + }, + "structured_mail_calendar": { + "add_to_calendar": "เพิ่มลงในปฏิทิน", + "update_in_calendar": "อัปเดตในปฏิทิน", + "remove_from_calendar": "นำออกจากปฏิทิน", + "in_calendar": "อยู่ในปฏิทินของคุณ", + "view_in_calendar": "เปิดดูในปฏิทิน", + "select_calendar": "เลือกปฏิทิน", + "added": "เพิ่มลงในปฏิทินของคุณแล้ว", + "updated": "อัปเดตปฏิทินแล้ว", + "removed": "นำออกจากปฏิทินของคุณแล้ว", + "action_failed": "เปลี่ยนแปลงปฏิทินไม่สำเร็จ โปรดลองอีกครั้ง", + "title": { + "rental_car": "รถเช่า", + "rental_car_company": "รถเช่า: {company}" + }, + "description": { + "reference": "หมายเลขการจอง: {reference}", + "passengers": "ผู้โดยสาร: {names}", + "seats": "ที่นั่ง: {seats}", + "checkin": "เช็กอิน: {url}", + "manage": "จัดการการจอง: {url}", + "ticket": "ตั๋ว: {url}", + "phone": "โทรศัพท์: {phone}", + "car": "รถ: {car}", + "tickets": "หมายเลขตั๋ว: {numbers}", + "party_size": "จำนวนคน: {count}", + "added_from": "เพิ่มจากอีเมลของ {domain}", + "added_from_subject": "เพิ่มจากอีเมล “{subject}” ของ {domain}" + } } } diff --git a/locales/tr/common.json b/locales/tr/common.json index cdf7a8505..6f09641bd 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Kolay okunur", "sign_language": "İşaret dili", "new_tab": "yeni sekmede açılır" + }, + "structured_mail_calendar": { + "add_to_calendar": "Takvime ekle", + "update_in_calendar": "Takvimde güncelle", + "remove_from_calendar": "Takvimden kaldır", + "in_calendar": "Takviminizde", + "view_in_calendar": "Takvimde görüntüle", + "select_calendar": "Bir takvim seçin", + "added": "Takviminize eklendi", + "updated": "Takvim güncellendi", + "removed": "Takviminizden kaldırıldı", + "action_failed": "Takviminiz değiştirilemedi. Tekrar deneyin.", + "title": { + "rental_car": "Kiralık araç", + "rental_car_company": "Kiralık araç: {company}" + }, + "description": { + "reference": "Rezervasyon kodu: {reference}", + "passengers": "Yolcular: {names}", + "seats": "Koltuklar: {seats}", + "checkin": "Check-in: {url}", + "manage": "Rezervasyonu yönet: {url}", + "ticket": "Bilet: {url}", + "phone": "Telefon: {phone}", + "car": "Araç: {car}", + "tickets": "Biletler: {numbers}", + "party_size": "Kişi sayısı: {count}", + "added_from": "{domain} adresinden gelen bir e-postadan eklendi", + "added_from_subject": "{domain} adresinden gelen “{subject}” e-postasından eklendi" + } } } diff --git a/locales/uk/common.json b/locales/uk/common.json index e645074a5..63ba95386 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5951,5 +5951,35 @@ "easy_read": "Легке читання", "sign_language": "Жестова мова", "new_tab": "відкривається в новій вкладці" + }, + "structured_mail_calendar": { + "add_to_calendar": "Додати в календар", + "update_in_calendar": "Оновити в календарі", + "remove_from_calendar": "Видалити з календаря", + "in_calendar": "У вашому календарі", + "view_in_calendar": "Переглянути в календарі", + "select_calendar": "Виберіть календар", + "added": "Додано до вашого календаря", + "updated": "Календар оновлено", + "removed": "Видалено з вашого календаря", + "action_failed": "Не вдалося змінити календар. Спробуйте ще раз.", + "title": { + "rental_car": "Оренда авто", + "rental_car_company": "Оренда авто: {company}" + }, + "description": { + "reference": "Номер бронювання: {reference}", + "passengers": "Пасажири: {names}", + "seats": "Місця: {seats}", + "checkin": "Реєстрація: {url}", + "manage": "Керувати бронюванням: {url}", + "ticket": "Квиток: {url}", + "phone": "Телефон: {phone}", + "car": "Автомобіль: {car}", + "tickets": "Квитки: {numbers}", + "party_size": "Кількість осіб: {count}", + "added_from": "Додано з листа від {domain}", + "added_from_subject": "Додано з листа «{subject}» від {domain}" + } } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4d628a87d..1105b77df 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5951,5 +5951,35 @@ "easy_read": "易讀版", "sign_language": "手語", "new_tab": "在新分頁中開啟" + }, + "structured_mail_calendar": { + "add_to_calendar": "新增到行事曆", + "update_in_calendar": "在行事曆中更新", + "remove_from_calendar": "從行事曆移除", + "in_calendar": "已在您的行事曆中", + "view_in_calendar": "在行事曆中檢視", + "select_calendar": "選取行事曆", + "added": "已新增到您的行事曆", + "updated": "行事曆已更新", + "removed": "已從您的行事曆移除", + "action_failed": "無法變更您的行事曆,請再試一次。", + "title": { + "rental_car": "租車", + "rental_car_company": "租車:{company}" + }, + "description": { + "reference": "訂位代號:{reference}", + "passengers": "乘客:{names}", + "seats": "座位:{seats}", + "checkin": "報到:{url}", + "manage": "管理訂位:{url}", + "ticket": "電子票:{url}", + "phone": "電話:{phone}", + "car": "車輛:{car}", + "tickets": "票號:{numbers}", + "party_size": "人數:{count}", + "added_from": "新增自 {domain} 寄來的郵件", + "added_from_subject": "新增自 {domain} 寄來的郵件「{subject}」" + } } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 80744b109..94f267ae0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5951,5 +5951,35 @@ "easy_read": "易读版", "sign_language": "手语", "new_tab": "在新标签页中打开" + }, + "structured_mail_calendar": { + "add_to_calendar": "添加到日历", + "update_in_calendar": "在日历中更新", + "remove_from_calendar": "从日历中移除", + "in_calendar": "已在你的日历中", + "view_in_calendar": "在日历中打开", + "select_calendar": "选择日历", + "added": "已添加到你的日历", + "updated": "日历已更新", + "removed": "已从你的日历中移除", + "action_failed": "无法更改你的日历,请重试。", + "title": { + "rental_car": "租车", + "rental_car_company": "租车:{company}" + }, + "description": { + "reference": "预订编号:{reference}", + "passengers": "乘客:{names}", + "seats": "座位:{seats}", + "checkin": "值机:{url}", + "manage": "管理预订:{url}", + "ticket": "电子票:{url}", + "phone": "电话:{phone}", + "car": "车辆:{car}", + "tickets": "票号:{numbers}", + "party_size": "人数:{count}", + "added_from": "添加自 {domain} 发来的邮件", + "added_from_subject": "添加自 {domain} 发来的邮件“{subject}”" + } } } From 521151b1f76c29a9a689423ef7ea186fc302370a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:17:29 +0100 Subject: [PATCH 4/6] test: keep the booking calendar picker inside a phone screen --- .../__tests__/use-booking-calendar.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx index 3018c2b6b..7b74e676d 100644 --- a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx +++ b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx @@ -272,6 +272,24 @@ describe('BookingCalendarActions', () => { expect(await screen.findByText('added')).toBeTruthy(); }); + it('keeps the picker inside a 390 px wide screen when the button sits at the right edge', async () => { + useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work')] }); + const width = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }); + try { + render(); + const button = await screen.findByRole('button', { name: /add_to_calendar/ }); + button.getBoundingClientRect = () => ({ left: 300, right: 382, top: 500, bottom: 536, width: 82, height: 36, x: 300, y: 500, toJSON: () => ({}) }); + fireEvent.click(button); + const menu = screen.getByRole('menu'); + const left = parseFloat(menu.style.left); + expect(left).toBeGreaterThanOrEqual(8); + expect(left + 208).toBeLessThanOrEqual(390 - 8); + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + } + }); + 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 { rerender } = render(); From 87d9f892381a7f7557d48305cdf4ee7c57798792 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:39:01 +0100 Subject: [PATCH 5/6] fix: update moved bookings in place and keep user edits when updating calendar events --- .../__tests__/calendar.test.ts | 138 ++++++++-- lib/structured-mail/calendar.ts | 236 ++++++++++++------ 2 files changed, 276 insertions(+), 98 deletions(-) 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; } From 3aac5a5c4c884ab1b06994e69fa58bb542b95a67 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:40:19 +0100 Subject: [PATCH 6/6] fix: show booking calendar state across cards, say when a plugin stops a change and make the calendar picker keyboard friendly --- .../__tests__/use-booking-calendar.test.tsx | 106 +++++++++++++++++- .../booking-calendar-actions.tsx | 77 ++++++++++--- .../structured-mail/use-booking-calendar.ts | 56 +++++++-- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 32 files changed, 240 insertions(+), 28 deletions(-) diff --git a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx index 7b74e676d..10977d55f 100644 --- a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx +++ b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx @@ -77,7 +77,11 @@ beforeEach(() => { vi.setSystemTime(NOW); client.queryCalendarEvents.mockReset(); calendarHolds([]); - createEvent.mockReset().mockImplementation(async (_client, event: Partial) => ({ ...event, id: `new-${event.uid}` })); + createEvent.mockReset().mockImplementation(async (_client, event: Partial) => { + const created = { ...event, id: `new-${event.uid}` } as CalendarEvent; + useCalendarStore.setState((s) => ({ events: [...s.events, created] })); + return created; + }); updateEvent.mockReset().mockResolvedValue(true); deleteEvent.mockReset().mockResolvedValue(true); setCalendarGate(true); @@ -148,6 +152,9 @@ describe('useBookingCalendar', () => { const [, id, patch, scheduling] = 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(scheduling).toBeUndefined(); expect(result.current.status).toBe('in-calendar'); }); @@ -241,12 +248,60 @@ describe('useBookingCalendar', () => { }); it('says why an add failed', async () => { - createEvent.mockResolvedValue(null); + createEvent.mockImplementation(async () => { + useCalendarStore.setState({ error: 'Failed to create event' }); + return null; + }); const { result } = await settled(await bookingFrom([OUT])); await act(() => result.current.add()); expect(result.current.error).toBe('action_failed'); expect(result.current.status).toBe('add'); }); + + it('says so when a plugin stops an add, an update or a removal', async () => { + createEvent.mockResolvedValue(null); + const fresh = await settled(await bookingFrom([OUT])); + await act(() => fresh.result.current.add()); + expect(fresh.result.current).toMatchObject({ notice: 'blocked', error: null, status: 'add' }); + + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + updateEvent.mockResolvedValue(false); + const moved = await settled(await bookingFrom([OUT_MOVED])); + await act(() => moved.result.current.update()); + expect(moved.result.current).toMatchObject({ notice: 'blocked', status: 'update' }); + + deleteEvent.mockResolvedValue(false); + const cancelled = await settled(await bookingFrom([OUT_CANCELLED])); + await act(() => cancelled.result.current.remove()); + expect(cancelled.result.current).toMatchObject({ notice: 'blocked', status: 'remove' }); + }); + + it('shows a booking added from another card for the same booking as in the calendar', async () => { + const booking = await bookingFrom([OUT]); + const first = await settled(booking); + const second = await settled(await bookingFrom([OUT])); + expect(first.result.current.status).toBe('add'); + expect(second.result.current.status).toBe('add'); + await act(() => second.result.current.add()); + expect(second.result.current.status).toBe('in-calendar'); + await waitFor(() => expect(first.result.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 (_client, id: string) => { + useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) })); + return true; + }); + const first = await settled(await bookingFrom([OUT_CANCELLED])); + const second = await settled(await bookingFrom([OUT_CANCELLED])); + expect(first.result.current.status).toBe('remove'); + await act(() => second.result.current.remove()); + await waitFor(() => expect(first.result.current.status).toBe('cancelled')); + }); }); describe('BookingCalendarActions', () => { @@ -272,6 +327,53 @@ describe('BookingCalendarActions', () => { expect(await screen.findByText('added')).toBeTruthy(); }); + async function openPicker() { + useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work'), cal('Travel')] }); + render(); + const trigger = await screen.findByRole('button', { name: /add_to_calendar/ }); + fireEvent.click(trigger); + return { trigger, menu: screen.getByRole('menu') }; + } + + it('moves focus into the picker and through it with the arrow keys, Home and End', async () => { + const { menu } = await openPicker(); + const items = screen.getAllByRole('menuitem'); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[1]); + fireEvent.keyDown(menu, { key: 'End' }); + expect(document.activeElement).toBe(items[2]); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: 'ArrowUp' }); + expect(document.activeElement).toBe(items[2]); + fireEvent.keyDown(menu, { key: 'Home' }); + expect(document.activeElement).toBe(items[0]); + expect(menu.querySelector('div')?.getAttribute('role')).toBe('presentation'); + }); + + it('closes the picker on Escape and gives focus back to its button', async () => { + const { trigger, menu } = await openPicker(); + fireEvent.keyDown(menu, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('stays open while its own list scrolls, and closes when the page scrolls', async () => { + const { menu } = await openPicker(); + fireEvent.scroll(menu); + expect(screen.queryByRole('menu')).not.toBeNull(); + fireEvent.scroll(window); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('puts focus on the outcome after a pick', async () => { + await openPicker(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Work' })); + await waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('status')); + expect(document.activeElement?.textContent).toBe('added'); + }); + it('keeps the picker inside a 390 px wide screen when the button sits at the right edge', async () => { useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work')] }); const width = window.innerWidth; diff --git a/components/email/structured-mail/booking-calendar-actions.tsx b/components/email/structured-mail/booking-calendar-actions.tsx index 657229845..217031697 100644 --- a/components/email/structured-mail/booking-calendar-actions.tsx +++ b/components/email/structured-mail/booking-calendar-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { createPortal } from 'react-dom'; import { useTranslations } from 'next-intl'; import { @@ -53,35 +53,77 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured const [picker, setPicker] = useState(null); const triggerRef = useRef(null); const pickerRef = useRef(null); + const rowRef = useRef(null); + // An action was started from here: once it is done, focus goes to its + // outcome instead of being lost with the button or picker that vanished. + const focusAfterAction = useRef(false); const closePicker = useCallback(() => setPicker(null), []); + const pickerOpen = picker !== null; useEffect(() => { - if (!picker) return; + if (!pickerOpen) return; + pickerRef.current?.querySelector('[role="menuitem"]')?.focus(); const onPointer = (event: MouseEvent | TouchEvent) => { const target = event.target as Node; if (pickerRef.current?.contains(target) || triggerRef.current?.contains(target)) return; closePicker(); }; + // Scrolling the list itself must not close it; scrolling the page moves the trigger away. + const onScroll = (event: Event) => { + if (event.target instanceof Node && pickerRef.current?.contains(event.target)) return; + closePicker(); + }; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { closePicker(); triggerRef.current?.focus(); } }; - window.addEventListener('scroll', closePicker, true); + window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', closePicker); document.addEventListener('mousedown', onPointer); document.addEventListener('touchstart', onPointer); document.addEventListener('keydown', onKey); return () => { - window.removeEventListener('scroll', closePicker, true); + window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', closePicker); document.removeEventListener('mousedown', onPointer); document.removeEventListener('touchstart', onPointer); document.removeEventListener('keydown', onKey); }; - }, [picker, closePicker]); + }, [pickerOpen, closePicker]); + + useEffect(() => { + if (busy || !focusAfterAction.current) return; + focusAfterAction.current = false; + const row = rowRef.current; + if (!row) return; + const target = row.querySelector('[role="alert"]') + ?? row.querySelector('[role="status"]') + ?? row.querySelector('button'); + target?.focus(); + }, [busy, status, notice, error]); + + const onMenuKey = (event: ReactKeyboardEvent) => { + const items = Array.from(pickerRef.current?.querySelectorAll('[role="menuitem"]') ?? []); + if (items.length === 0) return; + const at = items.indexOf(document.activeElement as HTMLElement); + let next: number | null = null; + if (event.key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % items.length; + else if (event.key === 'ArrowUp') next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else if (event.key === 'Tab') closePicker(); + if (next === null) return; + event.preventDefault(); + items[next].focus(); + }; + + const act = (action: () => Promise) => { + focusAfterAction.current = true; + void action(); + }; if (status === 'hidden' || status === 'loading') return null; // A cancelled booking with nothing in the calendar has no action; only the @@ -90,7 +132,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured const onAdd = () => { if (calendars.length <= 1) { - void calendar.add(calendars[0]?.id); + act(() => calendar.add(calendars[0]?.id)); return; } if (picker) { @@ -101,10 +143,11 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured }; const noticeText = notice === 'added' ? t('added') : notice === 'updated' ? t('updated') : notice === 'removed' ? t('removed') : null; + const blocked = notice === 'blocked'; const showView = status === 'in-calendar' || status === 'update'; return ( -
+
{status === 'add' && ( <> @@ -160,7 +205,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured {status === 'remove' && (
); diff --git a/components/email/structured-mail/use-booking-calendar.ts b/components/email/structured-mail/use-booking-calendar.ts index 5c30ea378..1d0c2800e 100644 --- a/components/email/structured-mail/use-booking-calendar.ts +++ b/components/email/structured-mail/use-booking-calendar.ts @@ -41,8 +41,8 @@ export interface BookingCalendar { 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; + /** What the last action did, for a short confirmation; `blocked` when a plugin stopped it. */ + notice: 'added' | 'updated' | 'removed' | 'blocked' | null; /** Add every live event, into `calendarId` or the default calendar. */ add: (calendarId?: string) => Promise; /** Add the missing live events, update the changed ones, remove the cancelled ones. */ @@ -52,6 +52,13 @@ export interface BookingCalendar { 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(); + type Lookup = { key: string; failed: false; events: Record } | { key: string; failed: true }; interface Plan { @@ -174,6 +181,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e const events: Record = {}; 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); @@ -200,11 +208,14 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e 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]; - if (!stored) return null; // The store's copy of the same event is fresher after a change elsewhere. - return storeEvents.find((e) => e.id === stored.id) ?? stored; - }, [known, storeEvents]); + 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 + && (!e.localAccountId || e.localAccountId === activeAccountId)) ?? null; + }, [known, storeEvents, activeAccountId]); const plan = useMemo(() => { if (!active) return { status: 'hidden', live: [], stale: [] }; @@ -219,14 +230,17 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e 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. */ + /** + * Run one action. `work` resolves to false when a plugin stopped a step, + * which is said as such; on failure say why and look the events up again. + */ const run = useCallback(async (done: NonNullable, work: () => Promise) => { if (!client || busy) return; setBusy(true); setError(null); setNotice(null); try { - if (await work()) setNotice(done); + setNotice((await work()) ? done : 'blocked'); } catch (err) { setError(describeError(err, t('action_failed'))); setRefresh((n) => n + 1); @@ -239,9 +253,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e if (!client) return false; for (const { slot, existing } of plan.live) { if (existing) continue; + // createEvent answers null both when a plugin stops it and when the + // server refuses (the uid exists after all, say); only the latter sets + // the store's error. + useCalendarStore.setState({ error: null }); const created = await createEvent(client, { ...slot.event, calendarIds: { [calendarId]: true } }); - // null: refused (a plugin, or the server, e.g. the uid exists after all). - if (!created) throw new Error('create failed'); + if (!created) { + if (useCalendarStore.getState().error) throw new Error('create failed'); + return false; + } + removedUids.delete(slot.uid); remember(slot.uid, created); } return true; @@ -250,7 +271,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e const removeStale = useCallback(async (): Promise => { if (!client) return false; for (const { slot, existing } of plan.stale) { - if (!(await deleteEvent(client, existing.id))) return false; + // Marked before the store drops the event, so other cards that + // re-render on that change already see it gone. + removedUids.add(slot.uid); + let removed = false; + try { + removed = await deleteEvent(client, existing.id); + } finally { + if (!removed) removedUids.delete(slot.uid); + } + if (!removed) return false; remember(slot.uid, null); } return true; @@ -266,7 +296,9 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e if (!client) return false; for (const { slot, existing } of plan.live) { if (!existing || !eventNeedsUpdate(existing, slot.event as Partial)) continue; - const patch = bookingEventPatch(existing, slot.event as Partial); + // Only what moved; title and notes the user changed stay. + const patch = bookingEventPatch(existing, slot.event as Partial, senderDomain); + if (Object.keys(patch).length === 0) continue; if (!(await updateEvent(client, existing.id, patch))) return false; const updated = { ...existing, ...patch } as CalendarEvent; remember(slot.uid, updated); @@ -281,7 +313,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e if (!(await createMissing(target))) return false; } return removeStale(); - }), [run, client, plan.live, targets, updateEvent, remember, createMissing, removeStale]); + }), [run, client, plan.live, senderDomain, targets, updateEvent, remember, createMissing, removeStale]); const remove = useCallback(() => run('removed', removeStale), [run, removeStale]); diff --git a/locales/ar/common.json b/locales/ar/common.json index f8a91e8b7..34d13ac31 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5963,6 +5963,7 @@ "updated": "تم تحديث التقويم", "removed": "تمت الإزالة من تقويمك", "action_failed": "تعذّر تغيير تقويمك. حاول مرة أخرى.", + "blocked": "أوقفت إضافةٌ هذا التغيير في التقويم.", "title": { "rental_car": "سيارة مستأجرة", "rental_car_company": "سيارة مستأجرة: {company}" diff --git a/locales/ca/common.json b/locales/ca/common.json index 06f73178c..260b3d728 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5963,6 +5963,7 @@ "updated": "S’ha actualitzat el calendari", "removed": "S’ha tret del vostre calendari", "action_failed": "No s’ha pogut modificar el calendari. Torneu-ho a provar.", + "blocked": "Un connector ha impedit aquest canvi al calendari.", "title": { "rental_car": "Cotxe de lloguer", "rental_car_company": "Cotxe de lloguer: {company}" diff --git a/locales/cs/common.json b/locales/cs/common.json index d64744cf2..1b5f5f140 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalendář aktualizován", "removed": "Odebráno z vašeho kalendáře", "action_failed": "Kalendář se nepodařilo změnit. Zkuste to znovu.", + "blocked": "Tuto změnu kalendáře zastavil plugin.", "title": { "rental_car": "Pronájem auta", "rental_car_company": "Pronájem auta: {company}" diff --git a/locales/da/common.json b/locales/da/common.json index 5abf5b6f5..9422d3b7d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalender opdateret", "removed": "Fjernet fra din kalender", "action_failed": "Din kalender kunne ikke ændres. Prøv igen.", + "blocked": "Et plugin stoppede denne ændring i kalenderen.", "title": { "rental_car": "Lejebil", "rental_car_company": "Lejebil: {company}" diff --git a/locales/de/common.json b/locales/de/common.json index 6572b214e..5750463d6 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalender aktualisiert", "removed": "Aus Ihrem Kalender entfernt", "action_failed": "Ihr Kalender konnte nicht geändert werden. Bitte versuchen Sie es erneut.", + "blocked": "Ein Plugin hat diese Kalenderänderung verhindert.", "title": { "rental_car": "Mietwagen", "rental_car_company": "Mietwagen: {company}" diff --git a/locales/en/common.json b/locales/en/common.json index f6e512ea1..c7b0120c2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendar updated", "removed": "Removed from your calendar", "action_failed": "Couldn't change your calendar. Try again.", + "blocked": "A plugin stopped this calendar change.", "title": { "rental_car": "Rental car", "rental_car_company": "Rental car: {company}" diff --git a/locales/es/common.json b/locales/es/common.json index f7cc461b1..7fb9d07be 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendario actualizado", "removed": "Quitado de tu calendario", "action_failed": "No se pudo cambiar tu calendario. Inténtalo de nuevo.", + "blocked": "Un complemento ha impedido este cambio en el calendario.", "title": { "rental_car": "Coche de alquiler", "rental_car_company": "Coche de alquiler: {company}" diff --git a/locales/et/common.json b/locales/et/common.json index f14c71c20..4e7881bbe 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalender uuendatud", "removed": "Eemaldatud sinu kalendrist", "action_failed": "Kalendrit ei õnnestunud muuta. Proovi uuesti.", + "blocked": "Pistikprogramm peatas selle kalendrimuudatuse.", "title": { "rental_car": "Rendiauto", "rental_car_company": "Rendiauto: {company}" diff --git a/locales/fa/common.json b/locales/fa/common.json index ba90429d4..48e8d82a2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5963,6 +5963,7 @@ "updated": "تقویم به‌روزرسانی شد", "removed": "از تقویم شما حذف شد", "action_failed": "تغییر تقویم انجام نشد. دوباره تلاش کنید.", + "blocked": "یک افزونه این تغییر تقویم را متوقف کرد.", "title": { "rental_car": "خودروی اجاره‌ای", "rental_car_company": "خودروی اجاره‌ای: {company}" diff --git a/locales/fr/common.json b/locales/fr/common.json index 91b97baad..1f33ac187 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendrier mis à jour", "removed": "Retiré de votre calendrier", "action_failed": "Impossible de modifier votre calendrier. Réessayez.", + "blocked": "Un plugin a empêché cette modification du calendrier.", "title": { "rental_car": "Voiture de location", "rental_car_company": "Voiture de location : {company}" diff --git a/locales/he/common.json b/locales/he/common.json index f2ec6680f..b909a9f6b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5964,6 +5964,7 @@ "updated": "היומן עודכן", "removed": "הוסר מהיומן שלך", "action_failed": "לא ניתן היה לשנות את היומן. נסה שוב.", + "blocked": "תוסף עצר את השינוי הזה ביומן.", "title": { "rental_car": "רכב שכור", "rental_car_company": "רכב שכור: {company}" diff --git a/locales/hu/common.json b/locales/hu/common.json index 66941bfb6..074d194cd 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5963,6 +5963,7 @@ "updated": "Naptár frissítve", "removed": "Eltávolítva a naptáradból", "action_failed": "Nem sikerült módosítani a naptárat. Próbáld újra.", + "blocked": "Egy bővítmény megakadályozta ezt a naptárváltozást.", "title": { "rental_car": "Bérautó", "rental_car_company": "Bérautó: {company}" diff --git a/locales/it/common.json b/locales/it/common.json index 12ddd4328..8c3201749 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendario aggiornato", "removed": "Rimosso dal tuo calendario", "action_failed": "Impossibile modificare il calendario. Riprova.", + "blocked": "Un plugin ha impedito questa modifica al calendario.", "title": { "rental_car": "Auto a noleggio", "rental_car_company": "Auto a noleggio: {company}" diff --git a/locales/ja/common.json b/locales/ja/common.json index 5f8b9f2df..24b42afab 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5963,6 +5963,7 @@ "updated": "カレンダーを更新しました", "removed": "カレンダーから削除しました", "action_failed": "カレンダーを変更できませんでした。もう一度お試しください。", + "blocked": "プラグインがこのカレンダーの変更を止めました。", "title": { "rental_car": "レンタカー", "rental_car_company": "レンタカー: {company}" diff --git a/locales/ko/common.json b/locales/ko/common.json index a4b9e68e1..6ec65a8ae 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5963,6 +5963,7 @@ "updated": "캘린더가 업데이트되었어요", "removed": "캘린더에서 제거되었어요", "action_failed": "캘린더를 변경할 수 없어요. 다시 시도해 주세요.", + "blocked": "플러그인이 이 캘린더 변경을 막았어요.", "title": { "rental_car": "렌터카", "rental_car_company": "렌터카: {company}" diff --git a/locales/lv/common.json b/locales/lv/common.json index 49676a909..45676e542 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalendārs atjaunināts", "removed": "Noņemts no jūsu kalendāra", "action_failed": "Neizdevās mainīt kalendāru. Mēģiniet vēlreiz.", + "blocked": "Spraudnis apturēja šīs kalendāra izmaiņas.", "title": { "rental_car": "Nomas auto", "rental_car_company": "Nomas auto: {company}" diff --git a/locales/mn/common.json b/locales/mn/common.json index 765452785..895b0ca85 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5963,6 +5963,7 @@ "updated": "Хуанли шинэчлэгдлээ", "removed": "Таны хуанлиас хассан", "action_failed": "Хуанлийг өөрчилж чадсангүй. Дахин оролдоно уу.", + "blocked": "Нэмэлт өргөтгөл энэ хуанлийн өөрчлөлтийг зогсоосон.", "title": { "rental_car": "Түрээсийн машин", "rental_car_company": "Түрээсийн машин: {company}" diff --git a/locales/nb/common.json b/locales/nb/common.json index 15cba65f7..307ab49ba 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalenderen er oppdatert", "removed": "Fjernet fra kalenderen din", "action_failed": "Kunne ikke endre kalenderen. Prøv igjen.", + "blocked": "Et programtillegg stoppet denne endringen i kalenderen.", "title": { "rental_car": "Leiebil", "rental_car_company": "Leiebil: {company}" diff --git a/locales/nl/common.json b/locales/nl/common.json index 68b4f213b..7261c9b13 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5963,6 +5963,7 @@ "updated": "Agenda bijgewerkt", "removed": "Uit je agenda verwijderd", "action_failed": "Je agenda kon niet worden gewijzigd. Probeer het opnieuw.", + "blocked": "Een plug-in heeft deze agendawijziging tegengehouden.", "title": { "rental_car": "Huurauto", "rental_car_company": "Huurauto: {company}" diff --git a/locales/pl/common.json b/locales/pl/common.json index 6144b0b4c..064d7df23 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5963,6 +5963,7 @@ "updated": "Zaktualizowano kalendarz", "removed": "Usunięto z Twojego kalendarza", "action_failed": "Nie udało się zmienić kalendarza. Spróbuj ponownie.", + "blocked": "Wtyczka zablokowała tę zmianę w kalendarzu.", "title": { "rental_car": "Wynajem samochodu", "rental_car_company": "Wynajem samochodu: {company}" diff --git a/locales/pt/common.json b/locales/pt/common.json index 129db2d24..19bb19a43 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendário atualizado", "removed": "Removido do seu calendário", "action_failed": "Não foi possível alterar seu calendário. Tente novamente.", + "blocked": "Um plugin impediu esta alteração no calendário.", "title": { "rental_car": "Carro alugado", "rental_car_company": "Carro alugado: {company}" diff --git a/locales/ro/common.json b/locales/ro/common.json index 942f61bf2..64bc9945a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5963,6 +5963,7 @@ "updated": "Calendar actualizat", "removed": "Eliminat din calendarul dvs.", "action_failed": "Calendarul nu a putut fi modificat. Încercați din nou.", + "blocked": "Un plugin a oprit această modificare în calendar.", "title": { "rental_car": "Mașină închiriată", "rental_car_company": "Mașină închiriată: {company}" diff --git a/locales/ru/common.json b/locales/ru/common.json index 49f2f69dc..c6b3973d5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5963,6 +5963,7 @@ "updated": "Календарь обновлён", "removed": "Удалено из вашего календаря", "action_failed": "Не удалось изменить календарь. Попробуйте ещё раз.", + "blocked": "Плагин остановил это изменение календаря.", "title": { "rental_car": "Аренда автомобиля", "rental_car_company": "Аренда автомобиля: {company}" diff --git a/locales/sk/common.json b/locales/sk/common.json index 1a89c85fd..e3ae59ba2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5963,6 +5963,7 @@ "updated": "Kalendár bol aktualizovaný", "removed": "Odstránené z vášho kalendára", "action_failed": "Kalendár sa nepodarilo zmeniť. Skúste to znova.", + "blocked": "Túto zmenu kalendára zastavil doplnok.", "title": { "rental_car": "Prenájom auta", "rental_car_company": "Prenájom auta: {company}" diff --git a/locales/th/common.json b/locales/th/common.json index e1fc28df3..f0559d5d1 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -5971,6 +5971,7 @@ "updated": "อัปเดตปฏิทินแล้ว", "removed": "นำออกจากปฏิทินของคุณแล้ว", "action_failed": "เปลี่ยนแปลงปฏิทินไม่สำเร็จ โปรดลองอีกครั้ง", + "blocked": "ปลั๊กอินหยุดการเปลี่ยนแปลงปฏิทินนี้", "title": { "rental_car": "รถเช่า", "rental_car_company": "รถเช่า: {company}" diff --git a/locales/tr/common.json b/locales/tr/common.json index 6f09641bd..797b8e4cf 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5963,6 +5963,7 @@ "updated": "Takvim güncellendi", "removed": "Takviminizden kaldırıldı", "action_failed": "Takviminiz değiştirilemedi. Tekrar deneyin.", + "blocked": "Bir eklenti bu takvim değişikliğini durdurdu.", "title": { "rental_car": "Kiralık araç", "rental_car_company": "Kiralık araç: {company}" diff --git a/locales/uk/common.json b/locales/uk/common.json index 63ba95386..1936daefd 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5963,6 +5963,7 @@ "updated": "Календар оновлено", "removed": "Видалено з вашого календаря", "action_failed": "Не вдалося змінити календар. Спробуйте ще раз.", + "blocked": "Плагін зупинив цю зміну календаря.", "title": { "rental_car": "Оренда авто", "rental_car_company": "Оренда авто: {company}" diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1105b77df..19007ee2a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5963,6 +5963,7 @@ "updated": "行事曆已更新", "removed": "已從您的行事曆移除", "action_failed": "無法變更您的行事曆,請再試一次。", + "blocked": "某個外掛程式阻止了此行事曆變更。", "title": { "rental_car": "租車", "rental_car_company": "租車:{company}" diff --git a/locales/zh/common.json b/locales/zh/common.json index 94f267ae0..c09f5b0d3 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5963,6 +5963,7 @@ "updated": "日历已更新", "removed": "已从你的日历中移除", "action_failed": "无法更改你的日历,请重试。", + "blocked": "某个插件阻止了此日历更改。", "title": { "rental_car": "租车", "rental_car_company": "租车:{company}"