From 1ec2d62b1cdb2f118bcd6afd9943aa4f0437961e Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 27 Jun 2026 16:50:43 +0200 Subject: [PATCH] fix(reservations): keep dated bookings on their date when the trip range shifts (#1288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing a trip's start date positionally re-dates the day rows (keeping their ids), so a dated booking's day_id stayed glued to a now-re-dated day and the booking visually shifted by the offset — until you re-opened and saved it. After a date-range change, non-hotel bookings are now re-anchored to the day matching their absolute reservation_time (the same derivation create/update already use). Bookings whose date falls outside the new range are left untouched; hotels and the relative positional shift of places/notes are unaffected. --- server/src/services/reservationService.ts | 28 ++++++++++++++++ server/src/services/tripService.ts | 8 +++-- .../tests/unit/services/tripService.test.ts | 33 ++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index fb31daac..39ddfeb9 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -59,6 +59,34 @@ function resolveDayIdFromTime( return row?.id ?? null; } +// After a trip's date range changes, generateDays positionally re-dates the day rows +// (keeping their ids), so a dated booking's day_id stays glued to a now-re-dated day and +// the booking visually shifts by the offset (#1288). Re-anchor non-hotel bookings to the +// day matching their absolute reservation_time — the same derivation create/updateReservation +// use. Only updates when a matching day exists, so a booking whose date now falls outside +// the new range is left untouched. Hotels keep their range on the linked day_accommodation. +export function resyncReservationDays(tripId: string | number): void { + const rows = db.prepare( + `SELECT id, reservation_time, reservation_end_time, day_id, end_day_id + FROM reservations + WHERE trip_id = ? AND type != 'hotel' AND reservation_time IS NOT NULL`, + ).all(tripId) as { + id: number; reservation_time: string | null; reservation_end_time: string | null; + day_id: number | null; end_day_id: number | null; + }[]; + const update = db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?'); + for (const r of rows) { + const newDayId = resolveDayIdFromTime(tripId, r.reservation_time); + if (newDayId == null) continue; + const newEndDayId = r.reservation_end_time + ? (resolveDayIdFromTime(tripId, r.reservation_end_time) ?? r.end_day_id) + : r.end_day_id; + if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) { + update.run(newDayId, newEndDayId, r.id); + } + } +} + function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void { // Bind the transaction lazily on each call. Binding at module load time // captures the DB connection that was open then, which becomes invalid diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index 85f90944..3267ff7c 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -5,7 +5,7 @@ import { Trip, User } from '../types'; import { listDays, listAccommodations } from './dayService'; import { listBudgetItems } from './budgetService'; import { listItems as listPackingItems } from './packingService'; -import { listReservations, loadEndpointsByTrip } from './reservationService'; +import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService'; import { listNotes as listCollabNotes } from './collabService'; import { shiftOwnerEntriesForTripWindow } from './vacayService'; @@ -256,8 +256,12 @@ export function updateTrip(tripId: string | number, userId: number, data: Update shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; - if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) + if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) { generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount); + // generateDays re-dates day rows positionally; re-anchor dated bookings to the day + // matching their absolute reservation_time so they don't shift with it (#1288). + resyncReservationDays(tripId); + } const changes: Record = {}; if (title && title !== trip.title) changes.title = title; diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts index d343846e..06afba63 100644 --- a/server/tests/unit/services/tripService.test.ts +++ b/server/tests/unit/services/tripService.test.ts @@ -34,7 +34,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories'; -import { exportICS, generateDays, deleteOldCover } from '../../../src/services/tripService'; +import { exportICS, generateDays, deleteOldCover, updateTrip } from '../../../src/services/tripService'; import fs from 'fs'; beforeAll(() => { @@ -476,3 +476,34 @@ describe('deleteOldCover', () => { } }); }); + +describe('resyncReservationDays (#1288)', () => { + const dayFor = (tripId: number, date: string) => + (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; + const insertDatedReservation = (tripId: number, dayId: number, time: string) => + Number(testDb.prepare( + "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')", + ).run(tripId, dayId, time).lastInsertRowid); + + it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00'); + // Shift the whole range one day forward (days become 2025-06-02..06). + updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); + const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + // The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row. + expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); + }); + + it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const origDayId = dayFor(trip.id, '2025-06-02'); + const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00'); + // Shift far forward so 2025-06-02 is no longer covered by any day. + updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user'); + const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + expect(res.day_id).toBe(origDayId); + }); +});