diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index fcdf259ef..ee70ced64 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -156,6 +156,9 @@ export class CalendarService { .prepare( `SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng, sd.date AS stay_start_date, ed.date AS stay_end_date, + a.check_in AS stay_check_in, a.check_out AS stay_check_out, + (SELECT MIN(r2.id) FROM reservations r2 + WHERE r2.accommodation_id = a.id) AS stay_first_reservation_id, rd.date AS day_date, red.date AS end_day_date FROM reservations r LEFT JOIN places pl ON r.place_id = pl.id @@ -343,13 +346,28 @@ export class CalendarService { // nights it contains. DTEND is exclusive, so it is check-out plus one day. // Dropping that +1 hides the departure day; it is not a stray off-by-one // (#1869). - if (isDate(r.stay_start_date)) { + // Unless the stay records BOTH ends of its clock, in which case the two + // timed markers below already say everything the block would, and saying + // it twice buries a week of calendar under a bar nobody can act on + // (#2136). Knowing only one end still needs the block: it is the only + // thing carrying the other end's date. + // + // Only for the booking the markers actually stand in for, though. They are + // 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; + 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 : r.stay_start_date; return `DTSTART;VALUE=DATE:${fmtDate(r.stay_start_date)}\r\n` + `DTEND;VALUE=DATE:${fmtDate(addDays(lastDay, 1))}\r\n`; } + // A fully timed stay is carried by its markers alone, so the booking row + // itself has nothing left to place. + if (isDate(r.stay_start_date)) return null; const eps = endpointsMap.get(r.id); const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : null; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index d77d5d499..8b15364c5 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -829,6 +829,57 @@ describe('accommodations', () => { expect(ics).toContain('SUMMARY:Hotel Bellevue'); }); + it('CAL-025b: a stay that records both ends of its clock drops the all-day block (#2136)', () => { + // The two markers already say when to arrive and when to leave, which is the + // part a subscriber can act on. Keeping the block as well buries the week + // under a bar that repeats what the markers say. + 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', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).not.toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); + expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); + expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue'); + }); + + it('CAL-025d: a second room on the same stay keeps its block, since no marker names it', () => { + // The markers are emitted once per stay and titled from its lowest-id + // booking, so dropping every block would leave the second one with nothing. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + const { stayId } = createStay(trip.id, { + start: '2026-07-07', end: '2026-07-12', + check_in: '15:00', check_out: '11:00', + }); + const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY id ASC LIMIT 1').get(trip.id) as { id: number }; + testDb.prepare(` + INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id) + VALUES (?, ?, 'Bellevue second room', NULL, 'confirmed', 'hotel', ?) + `).run(trip.id, day.id, String(stayId)); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Bellevue second room'); + expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); + }); + + 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' }); + createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_in: '15:00' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); + expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); + expect(ics).not.toContain('SUMMARY:Check-out'); + }); + it('CAL-026: check-in and check-out become their own timed events in the stay zone', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' });