fix(calendar): give stay check-in/check-out markers the one-hour handover window (#2136)

This commit is contained in:
Maurice
2026-09-01 15:07:25 +02:00
committed by Maurice
parent b9e8821114
commit 2e00c998c0
2 changed files with 58 additions and 2 deletions
+16 -2
View File
@@ -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`;
@@ -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' });