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]