From 681fe1fee382883915932c7df5d6d090bd0c55b1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 14 Aug 2026 13:27:23 +0200 Subject: [PATCH] fix(dashboard): keep hotels out of the upcoming reservations widget A stay covers a range rather than a moment, so one week in a hotel would hold a slot in a six-entry widget for the whole week and push out the bookings that actually happen on a day. The accommodation belongs to the day plan, which shows it across its span. They never worked here either, which is what the report is about. A hotel keeps its dates on the linked stay, not on the reservation, so the two ways one can exist behaved differently: The booking form writes reservation_time = NULL and sends no day_id, so a form-created hotel matched neither arm of the date test and was silently absent. That is the "incomplete" half. The accommodation panel auto-creates its booking with day_id and with the start day's date stamped into reservation_time. That row did show, but updateAccommodation syncs only metadata and the confirmation number to its linked bookings, so moving the stay left the stamp untouched and the widget kept the old date. That is the "no effect when I change the date" half. The type test is NULL-safe: the column defaults to 'other' but nothing stops a NULL, and a bare `type != 'hotel'` evaluates to NULL there and would have dropped those rows from the widget instead. Closes #1934 --- .../nest/reservations/reservations.service.ts | 15 +++++++++ server/tests/e2e/reservations.e2e.test.ts | 7 ++-- .../unit/nest/reservations.service.test.ts | 33 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 9d3b08f57..3f0a814db 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -384,6 +384,20 @@ export class ReservationsService { * as upcoming when its own time is in the future, or — for timeless entries — * when its day falls on or after today. Cancelled bookings are skipped. * The default limit (6) matches the legacy inline handler. + * + * Hotels are left out on purpose (#1934). A stay covers a range rather than a + * moment, so a week in one hotel would hold a slot in a six-entry widget for + * the whole week and push out the bookings that actually happen on a day. The + * accommodation belongs to the day plan, which shows it across its span. + * + * They also never worked here. A hotel keeps its dates on the linked stay, not + * on the reservation: the booking form writes reservation_time = NULL and no + * day_id, so a form-created hotel matched neither arm of the date test and was + * invisible. The one path that did produce a visible row, the accommodation + * panel's auto-created booking, stamps the start day into reservation_time + * once and never restamps it, so moving the stay left the widget showing the + * old date. Both halves of the report come from reading a hotel's date off + * fields hotels do not use. */ listUpcoming(userId: number, limit = 6) { const today = new Date().toISOString().slice(0, 10); @@ -402,6 +416,7 @@ export class ReservationsService { WHERE (t.user_id = ? OR tm.user_id IS NOT NULL) AND t.is_archived = 0 AND r.status != 'cancelled' + AND COALESCE(r.type, '') != 'hotel' AND ( (r.reservation_time IS NOT NULL AND r.reservation_time >= ?) OR (r.reservation_time IS NULL AND d.date IS NOT NULL AND d.date >= ?) diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 3af7e828c..bcac65060 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -112,11 +112,14 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401); }); - it('200 cross-trip upcoming reservations feed', async () => { + it('200 cross-trip upcoming reservations feed, without the hotels (#1934)', async () => { db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Flight', 'flight', '2999-01-01T10:00:00')").run(tripId); + db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Stay', 'hotel', '2999-01-01T09:00:00')").run(tripId); const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body.reservations.map((r: { title: string }) => r.title)).toContain('Flight'); + const titles = res.body.reservations.map((r: { title: string }) => r.title); + expect(titles).toContain('Flight'); + expect(titles).not.toContain('Stay'); }); it('404 when trip not accessible (reservations)', async () => { diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 68b20d1b0..60f0d94ed 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -301,6 +301,39 @@ describe('ReservationsService (DI-native, real SQL)', () => { const rows = svc.listUpcoming(user.id) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Future']); }); + + // #1934 — a stay covers a range, so it would hold a widget slot for its + // whole span. Both ways a hotel can be dated are kept out. + it('RESV-SVC-017b: leaves out a hotel dated through its own reservation_time', () => { + const { user, trip } = ownerTrip(); + const hotel = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); + testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', hotel.id); + const flight = createReservation(testDb, trip.id, { title: 'Flight' }); + testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-02T10:00:00', flight.id); + + const rows = svc.listUpcoming(user.id) as { title: string }[]; + expect(rows.map(r => r.title)).toEqual(['Flight']); + }); + + it('RESV-SVC-017c: leaves out a hotel dated through its day', () => { + const { user, trip } = ownerTrip(); + const day = createDay(testDb, trip.id, { date: '2999-06-01' }); + createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel', day_id: day.id }); + + const rows = svc.listUpcoming(user.id) as { title: string }[]; + expect(rows.map(r => r.title)).toEqual([]); + }); + + // The type column defaults to 'other' but nothing stops a NULL, and a + // NULL-unsafe `type != 'hotel'` would silently drop those rows. + it('RESV-SVC-017d: a reservation with no type at all still shows', () => { + const { user, trip } = ownerTrip(); + const res = createReservation(testDb, trip.id, { title: 'Untyped' }); + testDb.prepare('UPDATE reservations SET type = NULL, reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', res.id); + + const rows = svc.listUpcoming(user.id) as { title: string }[]; + expect(rows.map(r => r.title)).toEqual(['Untyped']); + }); }); describe('resyncReservationDays', () => {