mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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<number, any>();
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user