diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 1970c9bed..cf67fc89f 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -453,7 +453,10 @@ export class CalendarService { car: { start: 'Pickup', end: 'Drop-off' }, parking: { start: 'Drop-off', end: 'Pickup' }, }; - /** How long a hand-over reads as. Zero-length events collapse in most clients. */ + /** + * How long a hand-over — and a check-in/check-out marker without an + * until-clock — reads as. Zero-length events collapse in most clients. + */ const HANDOVER_MINUTES = 60; const plusMinutes = (date: string, time: string, minutes: number): { date: string; time: string } => { @@ -614,7 +617,18 @@ export class CalendarService { const ref = `${date}T00:00`; 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); + if (isTime(endTime)) { + ev += dtLine('DTEND', endTime!, zone, ref); + } 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 + // timed stay is represented by nothing else, several clients rendered + // the whole booking as clutter or hid it outright (#2136). plusMinutes + // carries the date across midnight, so a late check-out ends on the + // following day instead of before it started. + const stop = plusMinutes(date, time, HANDOVER_MINUTES); + 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`; ev += `END:VEVENT\r\n`; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 051d3250f..c14e44a6f 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -861,6 +861,10 @@ describe('accommodations', () => { 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'); + // With the block gone the markers are the booking's whole representation, so + // they carry the hand-over default hour instead of being points. + expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T160000'); + expect(ics).toContain('DTEND;TZID=Europe/Paris:20260712T120000'); }); it('CAL-025d: a second room on the same stay keeps its block, since no marker names it', () => { @@ -915,6 +919,44 @@ describe('accommodations', () => { expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); }); + it('CAL-026b: a check-in without an until-clock reads as one hour, not a point (#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: '15:00' }); + + const { ics } = svc.exportICS(trip.id); + + // Reporter variant 2: the lone timed end becomes a one-hour slot AND the + // all-day block stays, since it is the only carrier of the other end's date. + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T150000'); + expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T160000'); + expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); + }); + + it('CAL-026c: the check-out marker carries the same default hour', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_out: '11:00' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260712T110000'); + expect(ics).toContain('DTEND;TZID=Europe/Paris:20260712T120000'); + }); + + it('CAL-026d: a late check-out rolls the DTEND over midnight', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_out: '23:30' }); + + const { ics } = svc.exportICS(trip.id); + + // Without the day shift the DTEND would land before its DTSTART, and clients + // drop an event whose end precedes its start. + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260712T233000'); + expect(ics).toContain('DTEND;TZID=Europe/Paris:20260713T003000'); + }); + 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' });