From fc6843528ca5d81e577b49b5e1b44b41dd32d75d Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 2 Sep 2026 19:00:22 +0200 Subject: [PATCH] fix(calendar): a fully timed stay keeps its description and location (#2136) The markers replaced the booking's block but carried none of what it said: no DESCRIPTION (type, confirmation, notes) and no LOCATION fallback. The carrying booking is resolved once now, and the markers inherit its description and location exactly when the block is dropped. A check-in window ending before it starts reads as a late arrival past midnight instead of swallowing the event. --- server/src/nest/calendar/calendar.service.ts | 33 ++++++- .../tests/unit/nest/calendar.service.test.ts | 86 ++++++++++++++++++- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index cf67fc89f..39f49325f 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -329,6 +329,16 @@ export class CalendarService { return t && isTime(t) ? t : null; }; + // Per stay, the booking whose all-day block its two markers replace. Decided + // once here rather than twice, because the markers have to hand on exactly + // what the block they replaced would have said (#2136). + const stayCarriedByMarkers = new Map(); + for (const r of reservations) { + if (isTime(r.stay_check_in) && isTime(r.stay_check_out) && r.stay_first_reservation_id === r.id) { + stayCarriedByMarkers.set(Number(r.accommodation_id), r); + } + } + // Build the DTSTART/DTEND lines for a reservation, or null when it has no // calendar-placeable time. Transports keep a per-side wall clock + IANA zone // on their endpoints, so consult a timed departure endpoint FIRST: transports @@ -356,8 +366,7 @@ export class CalendarService { // emitted once per stay and titled from its lowest-id reservation, so a // second room on the same stay is represented by nothing else and keeps // its block. - const markersCover = isTime(r.stay_check_in) && isTime(r.stay_check_out) - && r.stay_first_reservation_id === r.id; + const markersCover = stayCarriedByMarkers.get(Number(r.accommodation_id)) === r; if (isDate(r.stay_start_date) && !markersCover) { const lastDay = isDate(r.stay_end_date) && r.stay_end_date >= r.stay_start_date ? r.stay_end_date @@ -606,6 +615,14 @@ export class CalendarService { const zone = resolveTimeZone(stay.place_lat, stay.place_lng); // No end day (the day row was removed) → check out on the arrival day. const checkOutDate = isDate(stay.end_date) ? stay.end_date : stay.start_date; + // Where these markers ARE the booking, they inherit what its block used to + // carry: confirmation number, notes, and (for a stay whose place holds no + // address) the booking's own location. Otherwise the block still says all + // of that, and repeating it would change a feed that already went out: the + // same rule the hand-overs follow (#2068, #2136). + const booking = stayCarriedByMarkers.get(stay.id); + const desc = booking ? describeReservation(booking) : ''; + const address = stay.place_address || (booking ? booking.location : null); const marker = ( kind: 'checkin' | 'checkout', @@ -618,7 +635,14 @@ export class CalendarService { let ev = `BEGIN:VEVENT\r\nUID:${uid(stay.id, kind)}\r\nDTSTAMP:${now}\r\n`; ev += dtLine('DTSTART', time, zone, ref); if (isTime(endTime)) { - ev += dtLine('DTEND', endTime!, zone, ref); + // An until-clock that is not later than the check-in is a late-arrival + // window running past midnight ("22:00 to 02:00"): nothing on the way + // in orders the two, and emitting it as recorded puts the DTEND before + // the DTSTART, which clients drop outright. Since the stay lost its + // all-day block there is nothing left to fall back on, so the window + // ends on the following day (#2136). + const endDate = endTime!.slice(0, 5) <= time.slice(0, 5) ? addDays(date, 1) : date; + ev += dtLine('DTEND', endTime!, zone, `${endDate}T00:00`); } else { // No recorded end → the same default hour the hand-overs read as. // Without a DTEND the marker is a zero-length point, and since a fully @@ -630,7 +654,8 @@ export class CalendarService { ev += dtLine('DTEND', stop.time, zone, `${stop.date}T00:00`); } ev += `SUMMARY:${esc(summary)}\r\n`; - if (stay.place_address) ev += `LOCATION:${esc(stay.place_address)}\r\n`; + if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`; + if (address) ev += `LOCATION:${esc(address)}\r\n`; ev += `END:VEVENT\r\n`; events.push(ev); }; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index c14e44a6f..5c9f568a2 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -804,6 +804,10 @@ describe('accommodations', () => { withReservation?: boolean; lat?: number; lng?: number; + address?: string | null; + confirmation?: string; + notes?: string; + location?: string; }, ) => { const place = createPlace(testDb, tripId, { @@ -811,7 +815,8 @@ describe('accommodations', () => { lat: opts.lat ?? 48.8566, lng: opts.lng ?? 2.3522, }); - testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('1 Rue de Rivoli', place.id); + testDb.prepare('UPDATE places SET address = ? WHERE id = ?') + .run(opts.address === undefined ? '1 Rue de Rivoli' : opts.address, place.id); const startDay = createDay(testDb, tripId, { date: opts.start ?? undefined }); const endDay = opts.end === undefined ? startDay @@ -826,9 +831,13 @@ describe('accommodations', () => { if (opts.withReservation !== false) { testDb.prepare(` - INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id) - VALUES (?, ?, ?, ?, 'confirmed', 'hotel', ?) - `).run(tripId, startDay.id, opts.title ?? 'Hotel Bellevue', opts.start, String(stayId)); + INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id, + confirmation_number, notes, location) + VALUES (?, ?, ?, ?, 'confirmed', 'hotel', ?, ?, ?, ?) + `).run( + tripId, startDay.id, opts.title ?? 'Hotel Bellevue', opts.start, String(stayId), + opts.confirmation ?? null, opts.notes ?? null, opts.location ?? null, + ); } return { stayId, placeId: place.id }; }; @@ -888,6 +897,58 @@ describe('accommodations', () => { expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); }); + it('CAL-025e: the markers of a fully timed stay carry what the dropped block said (#2136)', () => { + // Losing the block must not lose the confirmation number and the notes with + // it, the same handover the split window bookings do (#2068). + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { + start: '2026-07-07', end: '2026-07-12', + check_in: '15:00', check_out: '11:00', + confirmation: 'HTL-77291', notes: 'Key box code 4711', + }); + + const { ics } = svc.exportICS(trip.id); + + const unfolded = ics.replaceAll('\r\n ', ''); + const descriptions = unfolded.split('\r\n').filter(l => l.startsWith('DESCRIPTION:Type: hotel')); + expect(descriptions).toHaveLength(2); + expect(descriptions[0]).toContain('Confirmation: HTL-77291'); + expect(descriptions[0]).toContain('Key box code 4711'); + }); + + it('CAL-025f: a stay whose place has no address takes the booking location instead', () => { + // place_id is nullable (ON DELETE SET NULL) and an address is optional, so + // without the fallback the only two events left name no address at all. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { + start: '2026-07-07', end: '2026-07-12', + check_in: '15:00', check_out: '11:00', + address: null, location: '12 Hotel Street', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics.match(/LOCATION:12 Hotel Street/g)).toHaveLength(2); + }); + + it('CAL-025g: a stay that keeps its block does not repeat the description on its markers', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { + start: '2026-07-07', end: '2026-07-12', + check_in: '15:00', confirmation: 'HTL-77291', + }); + + const { ics } = svc.exportICS(trip.id); + + // The block is still emitted and still says it; saying it a second time on + // the check-in marker would change a feed that already went out. + const unfolded = ics.replaceAll('\r\n ', ''); + expect(unfolded.split('\r\n').filter(l => l.startsWith('DESCRIPTION:Type: hotel'))).toHaveLength(1); + }); + it('CAL-025c: knowing only one end keeps the block, since nothing else carries the other', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); @@ -957,6 +1018,23 @@ describe('accommodations', () => { expect(ics).toContain('DTEND;TZID=Europe/Paris:20260713T003000'); }); + it('CAL-026e: a check-in window that ends before it starts runs past midnight (#2136)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { + start: '2026-07-07', end: '2026-07-12', + check_in: '22:00', check_in_end: '02:00', check_out: '11:00', + }); + + const { ics } = svc.exportICS(trip.id); + + // A late-arrival window, and nothing on the way in (REST, MCP, plugin SDK) + // orders the two clocks. Emitted as recorded the DTEND precedes the DTSTART, + // clients drop the event, and since the block is gone the arrival is gone. + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T220000'); + expect(ics).toContain('DTEND;TZID=Europe/Paris:20260708T020000'); + }); + it('CAL-027: a stay without times emits the all-day range and nothing else', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' });