From eae2ade740db8f907c83d3bede2965a1d8e37acc Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 26 Jul 2026 11:38:02 +0200 Subject: [PATCH] fix(booking-import): only resolve leg days from local_date on a prefill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving every waypoint day from endpoint.local_date also changed the edit path, where it inverts the precedence the form uses everywhere else: local_date is denormalised and the server only restamps it in the day_id branch of insertDay, and only when reservation_time is set. Dragging a booking to another day rewrites day_id/end_day_id and leaves the endpoints alone, so local_date points at the old day — and reopening the form and pressing Update silently moved the booking back. Gate the lookup on a prefill, where there is no saved day to lose, and the edit path behaves exactly as it did before. Same change in the mobile sheet, which shares the seeding code and would otherwise still show empty day selectors on an imported booking. --- .../Planner/TransportModal.test.tsx | 43 +++++++++++++++++++ .../src/components/Planner/TransportModal.tsx | 29 ++++++++----- .../trip/sheets/MTransportFormSheet.tsx | 22 ++++++---- 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 4210988df..07a1d1303 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -513,4 +513,47 @@ describe('TransportModal', () => { expect(payload.metadata.legs).toBeUndefined(); expect(payload.metadata.train_number).toBe('RE 9'); }); + + // ── Per-endpoint day resolution (#1684) ──────────────────────────────────── + + const spanDays = [ + { id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }, + { id: 11, trip_id: 1, day_number: 2, date: '2026-08-02', title: 'Day 2' }, + { id: 12, trip_id: 1, day_number: 3, date: '2026-08-03', title: 'Day 3' }, + ] as any; + + const flightEndpoints = (fromDate: string, toDate: string) => ([ + { id: 1, reservation_id: 1, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: fromDate, local_time: '10:00' }, + { id: 2, reservation_id: 1, role: 'to', sequence: 1, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: toDate, local_time: '13:00' }, + ]); + + it('FE-PLANNER-TRANSMODAL-030: an import prefill resolves each waypoint day from its endpoint local_date (#1684)', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + // A parsed import carries local_date per endpoint but no day_id at all. + const prefill = { title: 'LH 400', type: 'flight', status: 'pending', endpoints: flightEndpoints('2026-08-02', '2026-08-03') } as any; + render(); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.day_id).toBe(11); + expect(payload.end_day_id).toBe(12); + }); + + it('FE-PLANNER-TRANSMODAL-031: editing keeps the saved days when the endpoint local_date is stale', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + // Dragging a booking to another day rewrites day_id/end_day_id but leaves the + // endpoints untouched, so local_date lags behind. Re-saving must not move the + // booking back to the day that stale date points at. + const reservation = buildReservation({ + id: 1, title: 'LH 400', type: 'flight', day_id: 10, end_day_id: 11, + reservation_time: '2026-08-01T10:00', reservation_end_time: '2026-08-02T13:00', + endpoints: flightEndpoints('2026-08-03', '2026-08-03'), + } as any) as any; + render(); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.day_id).toBe(10); + expect(payload.end_day_id).toBe(11); + }); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index c8d237f4d..9dadfb963 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -234,6 +234,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel meta_platform: meta.platform || '', meta_seat: meta.seat || '', }) + // Only an import prefill carries a per-endpoint local_date without a day_id. On an + // edit the saved day wins: local_date is denormalised and can lag behind after a + // day drag, insertDay or a trip-date shift, so resolving from it would silently + // move the booking to another day on a plain re-save. + const endpointDayId = (ep?: { local_date?: string | null } | null) => + reservation ? '' : resolveDayId(days, ep?.local_date) + if (type === 'flight') { const orderedEps = orderedEndpoints(src) const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : [] @@ -247,12 +254,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel return { airport: airportFromEndpoint(ep), // An import prefill gives each endpoint its own local_date but no day_id, so - // resolve the day from that date (mirrors the top-level start_day_id fallback) - // — otherwise the review's per-leg day selectors render empty even though the - // time, read from the same endpoint, is filled. - arrDayId: legInto?.arr_day_id ?? (resolveDayId(days, ep.local_date) || (isLast ? (src.end_day_id ?? '') : '')), + // resolve the day from that date — otherwise the review's per-leg day + // selectors render empty even though the time, read from the same + // endpoint, is filled. + arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')), arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''), - depDayId: legOut?.dep_day_id ?? (resolveDayId(days, ep.local_date) || (isFirst ? (src.day_id ?? '') : '')), + depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')), depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''), airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''), flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''), @@ -261,13 +268,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel }) } else { // Legacy flight with no (or partial) endpoints — seed two waypoints. - const dep = emptyWaypoint(resolveDayId(days, from?.local_date) || (src.day_id ?? '')) + const dep = emptyWaypoint(endpointDayId(from) || (src.day_id ?? '')) dep.airport = airportFromEndpoint(from) dep.depTime = splitReservationDateTime(src.reservation_time).time ?? '' dep.airline = meta.airline ?? '' dep.flight_number = meta.flight_number ?? '' dep.seat = meta.seat ?? '' - const arr = emptyWaypoint(resolveDayId(days, to?.local_date) || (src.end_day_id ?? src.day_id ?? '')) + const arr = emptyWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? '')) arr.airport = airportFromEndpoint(to) arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? '' wps = [dep, arr] @@ -291,9 +298,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel location: locationFromEndpoint(ep), // See the flight branch: resolve each station's day from its endpoint local_date // so an import prefill doesn't leave the per-leg day selectors empty. - arrDayId: legInto?.arr_day_id ?? (resolveDayId(days, ep.local_date) || (isLast ? (src.end_day_id ?? '') : '')), + arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')), arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''), - depDayId: legOut?.dep_day_id ?? (resolveDayId(days, ep.local_date) || (isFirst ? (src.day_id ?? '') : '')), + depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')), depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''), train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''), platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''), @@ -301,13 +308,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel } }) } else { - const dep = emptyStationWaypoint(resolveDayId(days, from?.local_date) || (src.day_id ?? '')) + const dep = emptyStationWaypoint(endpointDayId(from) || (src.day_id ?? '')) dep.location = locationFromEndpoint(from) dep.depTime = splitReservationDateTime(src.reservation_time).time ?? '' dep.train_number = meta.train_number ?? '' dep.platform = meta.platform ?? '' dep.seat = meta.seat ?? '' - const arr = emptyStationWaypoint(resolveDayId(days, to?.local_date) || (src.end_day_id ?? src.day_id ?? '')) + const arr = emptyStationWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? '')) arr.location = locationFromEndpoint(to) arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? '' wps = [dep, arr] diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index aa2eb0171..b269982e6 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -189,6 +189,12 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo confirmation_number: src.confirmation_number || '', notes: src.notes || '', }) + // Only an import prefill carries a per-endpoint local_date without a day_id. On an + // edit the saved day wins: local_date is denormalised and can lag behind after a + // day drag, insertDay or a trip-date shift (mirrors TransportModal). + const endpointDayId = (ep?: { local_date?: string | null } | null) => + editingTransport ? '' : resolveDayId(days, ep?.local_date) + if (type === 'flight') { const orderedEps = orderedEndpoints(src) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -202,9 +208,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo const isLast = i === orderedEps.length - 1 return { airport: airportFromEndpoint(ep), - arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''), + arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')), arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''), - depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''), + depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')), depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''), airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''), flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''), @@ -212,13 +218,13 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo } }) } else { - const dep = emptyWaypoint(src.day_id ?? '') + const dep = emptyWaypoint(endpointDayId(from) || (src.day_id ?? '')) dep.airport = airportFromEndpoint(from) dep.depTime = splitReservationDateTime(src.reservation_time).time ?? '' dep.airline = meta.airline ?? '' dep.flight_number = meta.flight_number ?? '' dep.seat = meta.seat ?? '' - const arr = emptyWaypoint(src.end_day_id ?? src.day_id ?? '') + const arr = emptyWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? '')) arr.airport = airportFromEndpoint(to) arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? '' wps = [dep, arr] @@ -239,9 +245,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo const isLast = i === orderedEps.length - 1 return { location: locationFromEndpoint(ep), - arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''), + arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')), arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''), - depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''), + depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')), depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''), train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''), platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''), @@ -249,13 +255,13 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo } }) } else { - const dep = emptyStationWaypoint(src.day_id ?? '') + const dep = emptyStationWaypoint(endpointDayId(from) || (src.day_id ?? '')) dep.location = locationFromEndpoint(from) dep.depTime = splitReservationDateTime(src.reservation_time).time ?? '' dep.train_number = meta.train_number ?? '' dep.platform = meta.platform ?? '' dep.seat = meta.seat ?? '' - const arr = emptyStationWaypoint(src.end_day_id ?? src.day_id ?? '') + const arr = emptyStationWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? '')) arr.location = locationFromEndpoint(to) arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? '' wps = [dep, arr]