fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar

Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.
This commit is contained in:
Maurice
2026-08-29 16:13:22 +02:00
committed by Maurice
parent 9d33bd71da
commit 2cac1ccd4f
2 changed files with 70 additions and 1 deletions
+19 -1
View File
@@ -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;
@@ -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' });