diff --git a/client/src/pages/tripPlanner/useRoadtripStopEdits.test.ts b/client/src/pages/tripPlanner/useRoadtripStopEdits.test.ts new file mode 100644 index 000000000..daa376498 --- /dev/null +++ b/client/src/pages/tripPlanner/useRoadtripStopEdits.test.ts @@ -0,0 +1,488 @@ +// FE-TP-STOPEDITS-001 to FE-TP-STOPEDITS-016 +// +// Edits to one stop on the drive, driven straight through useRoadtripStopEdits. The +// stop popup's draft and the stay release question are real state in the harness, so +// each case watches the hook open, answer and close them the way the planner does. +import { useState } from 'react' +import { act, renderHook } from '@testing-library/react' +import { accommodationsApi } from '../../api/client' +import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' +import { reanchorAfterInsert, reanchorAfterRemove, reanchorAfterReorder } from '../../components/Roadtrip/roadtripModel' +import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' +import type { RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes' +import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { resetAllStores } from '../../../tests/helpers/store' +import { buildAssignment, buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories' +import type { Accommodation, AssignmentsMap } from '../../types' +import type { Can, Translate } from './plannerTypes' +import type { PlannerDialogs } from './usePlannerDialogs' +import type { RoadtripFeed } from './useRoadtripFeed' +import { useRoadtripStopEdits } from './useRoadtripStopEdits' + +type Options = Parameters[0] +type Fixed = Omit +type Via = { id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number } + +const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } +const t: Translate = key => key +let allowed = true +const can: Can = vi.fn(() => allowed) +const trip = buildTrip({ id: 42 }) + +// Day 1 holds a note without coordinates between its stops; day 3 is empty. +const lake = buildPlace({ id: 1, lat: 50, lng: 10 }) +const note = buildPlace({ id: 2, lat: null, lng: null }) +const castle = buildPlace({ id: 3, lat: 50, lng: 11 }) +const pump = buildPlace({ id: 4, lat: 50, lng: 12 }) +const harbour = buildPlace({ id: 5, lat: 51, lng: 10 }) +const hotel = buildPlace({ id: 6, lat: 51, lng: 11 }) +const assignments: AssignmentsMap = { + 1: [ + buildAssignment({ id: 13, day_id: 1, place: castle, order_index: 2 }), + buildAssignment({ id: 11, day_id: 1, place: lake, order_index: 0 }), + buildAssignment({ id: 14, day_id: 1, place: pump, order_index: 3 }), + buildAssignment({ id: 12, day_id: 1, place: note, order_index: 1 }), + ], + 2: [ + buildAssignment({ id: 22, day_id: 2, place: hotel, order_index: 1 }), + buildAssignment({ id: 21, day_id: 2, place: harbour, order_index: 0 }), + ], + 3: [], +} +const routable: Record = { 1: [11, 13, 14], 2: [21, 22], 3: [] } + +function makeActions() { + return { + updatePlace: vi.fn(async () => undefined), + addPlace: vi.fn(async (): Promise<{ id: number } | null> => ({ id: 900 })), + assignPlaceToDay: vi.fn(async () => ({ id: 555 })), + refreshDays: vi.fn(async () => undefined), + reorderAssignments: vi.fn(async () => undefined), + setAssignmentEndDay: vi.fn(async () => undefined), + moveAssignment: vi.fn(async () => undefined), + } +} + +let actions: ReturnType +let vias: { byDay: Record; reanchor: ReturnType } +let boundaries: { boundaries: Array<{ day_number: number; from_assignment_id: number; to_assignment_id: number | null; fraction: number }>; save: ReturnType } +let fixed: Fixed + +const hit = (over: Partial = {}): CorridorPoi => ({ + osm_id: 'node/1', name: 'Rasthof', lat: 50, lng: 10.5, category: 'fuel', poi_type: 'fuel', + address: 'A7', website: 'https://rasthof.example', phone: null, opening_hours: null, cuisine: null, + source: 'osm', offRouteKm: 0.2, alongKm: 30, ...over, +}) +const stop = (over: Partial) => ({ assignmentId: 13, ownerDayId: 1, endDay: false, ...over }) as RoadtripStop + +function renderEdits(draft: RoadtripStopDraft | null = null, over: Partial = {}) { + const options = { ...fixed, ...over } + return renderHook(() => { + const [stopDraft, setStopDraft] = useState(draft) + const [stayRelease, setStayRelease] = useState(null) + const edits = useRoadtripStopEdits({ ...options, stopDraft, setStopDraft, stayRelease, setStayRelease }) + return { ...edits, stopDraft, stayRelease } + }) +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + resetAllStores() + allowed = true + actions = makeActions() + vias = { + byDay: { + 1: [ + { id: 7, day_id: 1, after_order_index: 0, sequence: 0, lat: 50, lng: 10.2 }, + { id: 8, day_id: 1, after_order_index: 0, sequence: 1, lat: 50, lng: 10.8 }, + { id: 9, day_id: 1, after_order_index: 1, sequence: 0, lat: 50, lng: 11.5 }, + ], + 2: [{ id: 10, day_id: 2, after_order_index: 0, sequence: 0, lat: 51, lng: 10.5 }], + }, + reanchor: vi.fn(async () => undefined), + } + boundaries = { boundaries: [], save: vi.fn(async () => true) } + fixed = { + tripId: 42, trip, can, toast, t, + tripActions: { ...useTripStore.getState(), ...actions } as unknown as TripStoreState, + assignments, tripAccommodations: [], reservations: [], + loadAccommodations: vi.fn(), + updateRouteForDay: vi.fn(async () => undefined), + roadtripVias: vias as unknown as RoadtripFeed['roadtripVias'], + // The via on the near side of the new stop stays on the first half of the split leg. + viaLiesBefore: (_dayId, at) => via => via.lng < at.lng, + roadtripStopsOf: dayId => (assignments[String(dayId)] ?? []) + .filter(a => routable[dayId]?.includes(a.id)) + .sort((a, b) => a.order_index - b.order_index), + dailyTimesActive: true, + dayBoundaries: boundaries as unknown as RoadtripFeed['dayBoundaries'], + } +}) + +describe('useRoadtripStopEdits', () => { + it('FE-TP-STOPEDITS-001: a new hit becomes a stop at its place in the drive, with the vias corrected first', async () => { + const order: string[] = [] + actions.assignPlaceToDay.mockImplementation(async () => { order.push('assign'); return { id: 555 } }) + vias.reanchor.mockImplementation(async () => { order.push('reanchor') }) + vi.mocked(fixed.updateRouteForDay).mockImplementation(async () => { order.push('route') }) + const { result } = renderEdits({ poi: hit(), dayId: 1, position: 1, dayNumber: 1 }) + + await act(async () => { await result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 15 }) }) + + expect(actions.addPlace).toHaveBeenCalledWith(42, { + name: 'Rasthof', lat: 50, lng: 10.5, address: 'A7', website: 'https://rasthof.example', phone: undefined, + osm_id: 'node/1', duration_minutes: 15, stop_type: 'fuel', + }) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 1, 900, 1) + expect(vias.reanchor).toHaveBeenCalledWith(1, { + vias: [{ id: 8, after_order_index: 1 }, { id: 9, after_order_index: 2 }], + remove: [], + }) + expect(order).toEqual(['assign', 'reanchor', 'route']) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(1) + expect(result.current.stopDraft).toBeNull() + expect(toast.success).toHaveBeenCalledWith('trip.toast.placeAdded') + }) + + it('FE-TP-STOPEDITS-002: without a draft nothing is saved, and a place that came back without an id is not assigned', async () => { + const empty = renderEdits() + await act(async () => { + await empty.result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 15 }) + await empty.result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '', checkOut: '' }) + }) + expect(actions.addPlace).not.toHaveBeenCalled() + + actions.addPlace.mockResolvedValueOnce(null) + const { result } = renderEdits({ poi: hit({ address: null, website: null, phone: '+49 1' }), dayId: 1, position: 1, dayNumber: 1 }) + await act(async () => { await result.current.saveStopDraft({ stopType: null, dwellMinutes: 30 }) }) + + expect(actions.addPlace).toHaveBeenCalledWith(42, expect.objectContaining({ address: null, website: undefined, phone: '+49 1' })) + expect(actions.assignPlaceToDay).not.toHaveBeenCalled() + expect(vias.reanchor).not.toHaveBeenCalled() + expect(result.current.stopDraft).toBeNull() + expect(toast.success).toHaveBeenCalledWith('trip.toast.placeAdded') + }) + + it('FE-TP-STOPEDITS-003: editing a stop rewrites its kind and stay and re-routes its day', async () => { + const editing = { placeId: 3, dwellMinutes: 20, stopType: null } + const { result } = renderEdits({ poi: hit(), dayId: 1, position: 2, dayNumber: 1, editing }) + + await act(async () => { await result.current.saveStopDraft({ stopType: 'rest_area', dwellMinutes: 25 }) }) + + expect(actions.updatePlace).toHaveBeenCalledWith(42, 3, { stop_type: 'rest_area', duration_minutes: 25 }) + expect(actions.addPlace).not.toHaveBeenCalled() + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(1) + expect(result.current.stopDraft).toBeNull() + expect(toast.success).not.toHaveBeenCalled() + }) + + it('FE-TP-STOPEDITS-004: turning a booked night into a pause asks first, and the yes drops only the night', async () => { + const deleteStay = vi.spyOn(accommodationsApi, 'delete').mockResolvedValue({ assignment: null }) + const stays = [{ id: 31, place_id: 3, reservation_title: 'Old Inn' }] as Accommodation[] + const reservations = [buildReservation({ id: 61, title: 'Castle Hotel', accommodation_id: 31 })] + const editing = { placeId: 3, dwellMinutes: 600, stopType: 'hotel' as const, accommodationId: 31 } + const draft = { poi: hit({ name: 'Castle' }), dayId: 1, position: 2, dayNumber: 1, editing } + const { result } = renderEdits(draft, { tripAccommodations: stays, reservations }) + + await act(async () => { await result.current.saveStopDraft({ stopType: 'rest_area', dwellMinutes: 30 }) }) + expect(result.current.stayRelease).toEqual({ stop: { stopType: 'rest_area', dwellMinutes: 30 }, name: 'Castle', booking: 'Castle Hotel' }) + expect(actions.updatePlace).not.toHaveBeenCalled() + expect(result.current.stopDraft).toBe(draft) + + await act(async () => { await result.current.confirmStayRelease() }) + expect(result.current.stayRelease).toBeNull() + expect(actions.updatePlace).toHaveBeenCalledWith(42, 3, { stop_type: 'rest_area', duration_minutes: 30 }) + expect(deleteStay).toHaveBeenCalledWith(42, 31, { keepStop: true }) + expect(fixed.loadAccommodations).toHaveBeenCalled() + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(1) + expect(result.current.stopDraft).toBeNull() + + // Nothing pending: the yes only closes the question. + await act(async () => { await result.current.confirmStayRelease() }) + expect(actions.updatePlace).toHaveBeenCalledTimes(1) + }) + + it('FE-TP-STOPEDITS-005: the question names the booking the stay remembers, or none', async () => { + const editing = { placeId: 3, dwellMinutes: 600, stopType: 'hotel' as const, accommodationId: 31 } + const draft = { poi: hit({ name: 'Castle' }), dayId: 1, position: 2, dayNumber: 1, editing } + + const remembered = renderEdits(draft, { tripAccommodations: [{ id: 31, reservation_title: 'Old Inn' }] as Accommodation[] }) + await act(async () => { await remembered.result.current.saveStopDraft({ stopType: null, dwellMinutes: 30 }) }) + expect(remembered.result.current.stayRelease?.booking).toBe('Old Inn') + + const unknown = renderEdits(draft) + await act(async () => { await unknown.result.current.saveStopDraft({ stopType: null, dwellMinutes: 30 }) }) + expect(unknown.result.current.stayRelease?.booking).toBeNull() + }) + + it('FE-TP-STOPEDITS-006: a save that fails keeps the popup open and says why', async () => { + const { result } = renderEdits({ poi: hit(), dayId: 1, position: 1, dayNumber: 1 }) + + actions.addPlace.mockRejectedValueOnce(new Error('offline')) + await act(async () => { await result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 15 }) }) + expect(toast.error).toHaveBeenLastCalledWith('offline') + expect(result.current.stopDraft).not.toBeNull() + + actions.addPlace.mockRejectedValueOnce('nope') + await act(async () => { await result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '', checkOut: '' }) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + expect(result.current.stopDraft).not.toBeNull() + }) + + it('FE-TP-STOPEDITS-007: a hit kept as a night becomes a stop with its booking, the times only when given', async () => { + const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue({}) + const draft = { poi: hit({ name: 'Camping Elbe', category: 'campsite' }), dayId: 1, position: 1, dayNumber: 1 } + const { result } = renderEdits(draft) + + await act(async () => { await result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '15:00', checkOut: '' }) }) + + expect(actions.addPlace).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Camping Elbe', stop_type: 'campsite' })) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 1, 900, 1) + expect(create).toHaveBeenCalledWith(42, { place_id: 900, start_day_id: 1, end_day_id: 2, check_in: '15:00' }) + expect(fixed.loadAccommodations).toHaveBeenCalled() + expect(vias.reanchor).toHaveBeenCalledWith(1, expect.objectContaining({ remove: [] })) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(1) + expect(result.current.stopDraft).toBeNull() + expect(toast.success).toHaveBeenCalledWith('roadtrip.stay.nightAdded') + + // A hotel hit, both times given, and a place that came back without an id. + const second = renderEdits({ poi: hit({ category: 'hotel' }), dayId: 1, position: 1, dayNumber: 1 }) + await act(async () => { await second.result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '15:00', checkOut: '10:00' }) }) + expect(actions.addPlace).toHaveBeenLastCalledWith(42, expect.objectContaining({ stop_type: 'hotel' })) + expect(create).toHaveBeenLastCalledWith(42, { place_id: 900, start_day_id: 1, end_day_id: 2, check_in: '15:00', check_out: '10:00' }) + + actions.addPlace.mockResolvedValueOnce(null) + const third = renderEdits({ poi: hit({ category: 'hotel' }), dayId: 1, position: 1, dayNumber: 1 }) + await act(async () => { await third.result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '', checkOut: '' }) }) + expect(create).toHaveBeenCalledTimes(2) + expect(third.result.current.stopDraft).toBeNull() + }) + + it('FE-TP-STOPEDITS-008: an existing stop kept as a night books it, or moves the booking it has', async () => { + const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue({}) + const moved = buildAssignment({ id: 13, day_id: 2, place: castle }) + const update = vi.spyOn(accommodationsApi, 'update').mockResolvedValue({ movedAssignment: { assignment: moved, oldDayId: 1 } }) + const handleRemoteEvent = vi.fn() + useTripStore.setState({ handleRemoteEvent }) + + const fresh = renderEdits({ poi: hit({ category: 'campsite' }), dayId: 1, position: 2, dayNumber: 1, editing: { placeId: 3, dwellMinutes: 20, stopType: null } }) + await act(async () => { await fresh.result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '', checkOut: '' }) }) + expect(create).toHaveBeenCalledWith(42, { place_id: 3, start_day_id: 1, end_day_id: 2, check_in: null, check_out: null }) + expect(actions.refreshDays).not.toHaveBeenCalled() + expect(actions.updatePlace).toHaveBeenCalledWith(42, 3, { stop_type: 'campsite' }) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(1) + expect(fresh.result.current.stopDraft).toBeNull() + + const booked = { placeId: 3, dwellMinutes: 600, stopType: 'hotel' as const, accommodationId: 31 } + const rebooked = renderEdits({ poi: hit({ category: 'hotel' }), dayId: 1, position: 2, dayNumber: 1, editing: booked }) + await act(async () => { await rebooked.result.current.saveStopDraftAsNight({ endDayId: 3, checkIn: '16:00', checkOut: '09:00' }) }) + expect(update).toHaveBeenCalledWith(42, 31, { place_id: 3, start_day_id: 1, end_day_id: 3, check_in: '16:00', check_out: '09:00' }) + // The moved stop is folded in for this session, and its day read back whole. + expect(handleRemoteEvent).toHaveBeenCalledWith({ type: 'assignment:moved', assignment: moved, oldDayId: 1, newDayId: 2 }) + expect(actions.refreshDays).toHaveBeenCalledWith(42) + expect(actions.updatePlace).toHaveBeenLastCalledWith(42, 3, { stop_type: 'hotel' }) + expect(rebooked.result.current.stopDraft).toBeNull() + }) + + it('FE-TP-STOPEDITS-009: kind, fill and stay are one field each, behind the edit right', async () => { + const { result, rerender } = renderEdits() + + await act(async () => { + await result.current.setRoadtripStopKind(3, 'fuel') + await result.current.setRoadtripStopFill(3, 80) + await result.current.setRoadtripStay(3, 0) + }) + expect(actions.updatePlace).toHaveBeenNthCalledWith(1, 42, 3, { stop_type: 'fuel' }) + expect(actions.updatePlace).toHaveBeenNthCalledWith(2, 42, 3, { fill_percent: 80 }) + expect(actions.updatePlace).toHaveBeenNthCalledWith(3, 42, 3, { duration_minutes: 0 }) + + actions.updatePlace.mockRejectedValueOnce(new Error('locked')).mockRejectedValueOnce('?').mockRejectedValueOnce(new Error('gone')) + await act(async () => { + await result.current.setRoadtripStopKind(3, null) + await result.current.setRoadtripStopFill(3, null) + await result.current.setRoadtripStay(3, 45) + }) + expect(toast.error.mock.calls.map(call => call[0])).toEqual(['locked', 'common.unknownError', 'gone']) + + allowed = false + rerender() + await act(async () => { + await result.current.setRoadtripStopKind(3, 'fuel') + await result.current.setRoadtripStopFill(3, 50) + await result.current.setRoadtripStay(3, 10) + }) + expect(actions.updatePlace).toHaveBeenCalledTimes(6) + }) + + it('FE-TP-STOPEDITS-010: a reorder from the rail is rebuilt on the day\'s whole list, and the vias follow', async () => { + const { result } = renderEdits() + + // The rail's first slot is the lake; the pump goes in front of it, the hidden note keeps its row. + await act(async () => { await result.current.reorderRoadtripStop(1, 14, 0) }) + expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 1, [14, 11, 12, 13]) + expect(vias.reanchor).toHaveBeenCalledWith(1, reanchorAfterReorder(vias.byDay[1], 2, 0, 3)) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(1) + + // A row the rail never showed moves without touching the vias. + vias.reanchor.mockClear() + await act(async () => { await result.current.reorderRoadtripStop(1, 12, 9) }) + expect(actions.reorderAssignments).toHaveBeenLastCalledWith(42, 1, [11, 13, 14, 12]) + expect(vias.reanchor).not.toHaveBeenCalled() + + actions.reorderAssignments.mockRejectedValueOnce(new Error('conflict')) + await act(async () => { await result.current.reorderRoadtripStop(1, 13, 0) }) + expect(toast.error).toHaveBeenLastCalledWith('conflict') + }) + + it('FE-TP-STOPEDITS-011: a reorder that goes nowhere, or that may not happen, writes nothing', async () => { + const { result, rerender } = renderEdits() + + await act(async () => { + await result.current.reorderRoadtripStop(1, 11, -3) + await result.current.reorderRoadtripStop(1, 99, 0) + await result.current.reorderRoadtripStop(5, 11, 0) + }) + expect(actions.reorderAssignments).not.toHaveBeenCalled() + + allowed = false + rerender() + await act(async () => { await result.current.reorderRoadtripStop(1, 14, 0) }) + expect(actions.reorderAssignments).not.toHaveBeenCalled() + }) + + it('FE-TP-STOPEDITS-012: a day ends at a stop by its own flag or by a boundary filed against it', async () => { + boundaries.boundaries = [{ day_number: 1, from_assignment_id: 13, to_assignment_id: null, fraction: 1 }] + const { result, rerender } = renderEdits() + + expect(result.current.roadtripEndsDayAt(stop({ assignmentId: 14, endDay: true }))).toBe(true) + expect(result.current.roadtripEndsDayAt(stop({ assignmentId: 13 }))).toBe(true) + expect(result.current.roadtripEndsDayAt(stop({ assignmentId: 11 }))).toBe(false) + + // The boundary is what ended it, so clearing it is the whole switch. + let moved: boolean | undefined + await act(async () => { moved = await result.current.setRoadtripEndDay(stop({ assignmentId: 13 })) }) + expect(moved).toBe(true) + expect(boundaries.save).toHaveBeenCalledWith(1, null) + expect(actions.setAssignmentEndDay).not.toHaveBeenCalled() + + // Both at once: the boundary goes and the flag is cleared too. + await act(async () => { moved = await result.current.setRoadtripEndDay(stop({ assignmentId: 13, endDay: true })) }) + expect(actions.setAssignmentEndDay).toHaveBeenCalledWith(42, 1, 13, false) + + await act(async () => { moved = await result.current.setRoadtripEndDay(stop({ assignmentId: 21, ownerDayId: 2 })) }) + expect(moved).toBe(true) + expect(actions.setAssignmentEndDay).toHaveBeenLastCalledWith(42, 2, 21, true) + + actions.setAssignmentEndDay.mockRejectedValueOnce(new Error('offline')) + await act(async () => { moved = await result.current.setRoadtripEndDay(stop({ assignmentId: 21, ownerDayId: 2 })) }) + expect(moved).toBe(false) + expect(toast.error).toHaveBeenLastCalledWith('offline') + + actions.setAssignmentEndDay.mockRejectedValueOnce(0) + await act(async () => { moved = await result.current.setRoadtripEndDay(stop({ assignmentId: 21, ownerDayId: 2 })) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + + allowed = false + rerender() + await act(async () => { moved = await result.current.setRoadtripEndDay(stop({ assignmentId: 21, ownerDayId: 2 })) }) + expect(moved).toBe(false) + expect(actions.setAssignmentEndDay).toHaveBeenCalledTimes(4) + }) + + it('FE-TP-STOPEDITS-013: without daily times there is no day end to move', async () => { + const { result } = renderEdits(null, { dailyTimesActive: false }) + let moved: boolean | undefined + await act(async () => { moved = await result.current.setRoadtripEndDay(stop({ assignmentId: 13 })) }) + expect(moved).toBe(false) + expect(actions.setAssignmentEndDay).not.toHaveBeenCalled() + }) + + it('FE-TP-STOPEDITS-014: a stop moved onto another day corrects the vias on both', async () => { + const { result, rerender } = renderEdits() + + await act(async () => { await result.current.moveRoadtripStopToDay(1, 13, 2, 1) }) + expect(actions.moveAssignment).toHaveBeenCalledWith(42, 13, 1, 2, 1) + expect(vias.reanchor).toHaveBeenNthCalledWith(1, 1, reanchorAfterRemove(vias.byDay[1], 1, 3)) + expect(vias.reanchor).toHaveBeenNthCalledWith(2, 2, reanchorAfterInsert(vias.byDay[2], 1, () => true)) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(1) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(2) + + // A row the rail never counted lands at the end of an empty day and moves no via. + vias.reanchor.mockClear() + await act(async () => { await result.current.moveRoadtripStopToDay(1, 12, 3, 5) }) + expect(actions.moveAssignment).toHaveBeenLastCalledWith(42, 12, 1, 3, 0) + expect(vias.reanchor).not.toHaveBeenCalled() + + actions.moveAssignment.mockRejectedValueOnce(new Error('full')) + await act(async () => { await result.current.moveRoadtripStopToDay(1, 14, 2, 0) }) + expect(toast.error).toHaveBeenLastCalledWith('full') + + await act(async () => { await result.current.moveRoadtripStopToDay(1, 13, 1, 0) }) + allowed = false + rerender() + await act(async () => { await result.current.moveRoadtripStopToDay(1, 13, 2, 0) }) + expect(actions.moveAssignment).toHaveBeenCalledTimes(3) + }) + + it('FE-TP-STOPEDITS-015: the stay dialog opens on the stop it was asked for and closes again', () => { + const { result } = renderEdits() + expect(result.current.stayDraft).toBeNull() + + const draft = { placeId: 3, name: 'Castle', minutes: 45 } + act(() => { result.current.editRoadtripStay(draft) }) + expect(result.current.stayDraft).toBe(draft) + + act(() => { result.current.setStayDraft(null) }) + expect(result.current.stayDraft).toBeNull() + }) + + it('FE-TP-STOPEDITS-016: a day without vias is corrected from an empty list, and any failure is reported', async () => { + vias.byDay = {} + const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue(undefined) + const draft = { poi: hit({ address: null, category: 'hotel' }), dayId: 1, position: 1, dayNumber: 1 } + const empty = { vias: [], remove: [] } + + const first = renderEdits(draft) + await act(async () => { await first.result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 15 }) }) + expect(vias.reanchor).toHaveBeenLastCalledWith(1, empty) + + const night = renderEdits(draft) + await act(async () => { await night.result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '', checkOut: '10:00' }) }) + expect(actions.addPlace).toHaveBeenLastCalledWith(42, expect.objectContaining({ address: null, website: 'https://rasthof.example' })) + expect(create).toHaveBeenCalledWith(42, { place_id: 900, start_day_id: 1, end_day_id: 2, check_out: '10:00' }) + expect(vias.reanchor).toHaveBeenLastCalledWith(1, empty) + + const edited = renderEdits({ ...draft, editing: { placeId: 3, dwellMinutes: 20, stopType: null } }) + await act(async () => { await edited.result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '', checkOut: '' }) }) + expect(actions.refreshDays).not.toHaveBeenCalled() + + const { result } = renderEdits(draft) + await act(async () => { + await result.current.reorderRoadtripStop(1, 14, 0) + await result.current.moveRoadtripStopToDay(1, 13, 2, 1) + }) + expect(vias.reanchor).toHaveBeenCalledWith(1, reanchorAfterReorder([], 2, 0, 3)) + expect(vias.reanchor).toHaveBeenCalledWith(1, reanchorAfterRemove([], 1, 3)) + expect(vias.reanchor).toHaveBeenCalledWith(2, reanchorAfterInsert([], 1, () => true)) + + toast.error.mockClear() + actions.addPlace.mockRejectedValueOnce('?').mockRejectedValueOnce(new Error('no room')) + actions.updatePlace.mockRejectedValueOnce('?').mockRejectedValueOnce(new Error('locked')).mockRejectedValueOnce('?') + actions.reorderAssignments.mockRejectedValueOnce('?') + actions.moveAssignment.mockRejectedValueOnce('?') + await act(async () => { + await result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 15 }) + await result.current.saveStopDraftAsNight({ endDayId: 2, checkIn: '', checkOut: '' }) + await result.current.setRoadtripStopKind(3, 'fuel') + await result.current.setRoadtripStopFill(3, 40) + await result.current.setRoadtripStay(3, 15) + await result.current.reorderRoadtripStop(1, 14, 0) + await result.current.moveRoadtripStopToDay(1, 13, 2, 1) + }) + expect(toast.error.mock.calls.map(call => call[0])).toEqual([ + 'common.unknownError', 'no room', 'common.unknownError', 'locked', 'common.unknownError', + 'common.unknownError', 'common.unknownError', + ]) + }) +}) diff --git a/client/src/pages/tripPlanner/useRoadtripStopEdits.ts b/client/src/pages/tripPlanner/useRoadtripStopEdits.ts new file mode 100644 index 000000000..e2308a4cc --- /dev/null +++ b/client/src/pages/tripPlanner/useRoadtripStopEdits.ts @@ -0,0 +1,406 @@ +import { useCallback, useState } from 'react' +import type { RoadtripStopType } from '@trek/shared' +import { accommodationsApi } from '../../api/client' +import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal' +import { reanchorAfterInsert, reanchorAfterRemove, reanchorAfterReorder } from '../../components/Roadtrip/roadtripModel' +import type { RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes' +import type { useRouteCalculation } from '../../hooks/useRouteCalculation' +import { applyStayStops } from '../../store/stayStops' +import type { TripStoreState } from '../../store/tripStore' +import type { Accommodation } from '../../types' +import type { PlannerBase } from './plannerTypes' +import type { PlannerDialogs } from './usePlannerDialogs' +import type { RoadtripFeed } from './useRoadtripFeed' + +interface RoadtripStopEditsOptions + extends Pick, + Pick, + Pick { + assignments: TripStoreState['assignments'] + tripAccommodations: Accommodation[] + reservations: TripStoreState['reservations'] + /** Reloads the booked nights useTripPlanner holds. */ + loadAccommodations: () => void + updateRouteForDay: ReturnType['updateRouteForDay'] +} + +/** + * Edits to one stop on the drive: saving the stop popup as a pause or as a night, + * releasing a booked night, the kind, fill and stay of a stop, moving it within its day + * or onto another one from the rail, ending the day at it, and the stay dialog. + * + * Nothing here runs an effect, so where useTripPlanner calls it changes nothing. + */ +export function useRoadtripStopEdits(options: RoadtripStopEditsOptions) { + const { + tripId, trip, can, tripActions, toast, t, assignments, tripAccommodations, reservations, loadAccommodations, + updateRouteForDay, stopDraft, setStopDraft, stayRelease, setStayRelease, + roadtripVias, viaLiesBefore, roadtripStopsOf, dailyTimesActive, dayBoundaries, + } = options + /** + * Saves a corridor hit as a stop: the place itself, then its position in the day. + * + * `stop_type` is what makes it a fuel stop rather than a place that happens to sell + * fuel: the road-trip kinds are their own dimension, deliberately not one of the + * traveller's editable categories, so the palette and the meaning stay put. + */ + const saveStopDraft = useCallback(async ( + { stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }, + { releaseStay = false }: { releaseStay?: boolean } = {}, + ) => { + if (!stopDraft) return + const { poi, dayId, position } = stopDraft + const accommodationId = stopDraft.editing?.accommodationId + // Turning a booked night into a pause deletes the booking, and with it the + // reservation and the expense the server keeps against it. The popup stays open + // behind the question, so a no leaves the traveller exactly where they were. + if (accommodationId && !releaseStay) { + const stay = tripAccommodations.find(s => s.id === accommodationId) + const booking = reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === accommodationId)?.title + ?? stay?.reservation_title ?? null + setStayRelease({ stop: { stopType, dwellMinutes }, name: poi.name, booking }) + return + } + try { + if (stopDraft.editing) { + await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes }) + if (accommodationId) { + // The night is what was switched off, not the stop: it stays where it is + // in the drive and becomes an ordinary pause. + applyStayStops(await accommodationsApi.delete(tripId, accommodationId, { keepStop: true })) + await loadAccommodations() + } + updateRouteForDay(dayId) + setStopDraft(null) + return + } + const place = await tripActions.addPlace(tripId, { + name: poi.name, + lat: poi.lat, + lng: poi.lng, + address: poi.address || null, + website: poi.website || undefined, + phone: poi.phone || undefined, + osm_id: poi.osm_id, + duration_minutes: dwellMinutes, + stop_type: stopType, + }) + if (place?.id) { + // Worked out BEFORE the stop lands, against the day as it stands and the road as + // it is currently driven: once the list has shifted there is no record of which + // leg each via was drawn for. + const plan = reanchorAfterInsert( + roadtripVias.byDay[dayId] ?? [], + position, + viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }), + ) + await tripActions.assignPlaceToDay(tripId, dayId, place.id, position) + // Awaited before the day re-routes: the routing effect reads the anchors against + // the new stop list, so a correction landing after it would draw the wrong road + // first and the right one a moment later. + await roadtripVias.reanchor(dayId, plan) + updateRouteForDay(dayId) + } + setStopDraft(null) + toast.success(t('trip.toast.placeAdded')) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, tripAccommodations, reservations, setStayRelease, setStopDraft]) + + /** The yes to the question above: the same save, this time allowed to drop the night. */ + const confirmStayRelease = useCallback(async () => { + const pending = stayRelease + setStayRelease(null) + if (pending) await saveStopDraft(pending.stop, { releaseStay: true }) + }, [stayRelease, saveStopDraft, setStayRelease]) + + const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: { + endDayId: number + checkIn: string + checkOut: string + }) => { + if (!stopDraft) return + const { poi, dayId, position } = stopDraft + try { + if (stopDraft.editing) { + const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null } + // The answer carries the day stop the booking moved or added. The socket + // deliberately skips the session that sent the request, so without folding + // it in the one person whose rail still shows the night at its old place in + // the chain is the one who just moved its check-in. + const written = stopDraft.editing.accommodationId + ? await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking) + : await accommodationsApi.create(tripId, booking) + applyStayStops(written) + // A night seated by a new check-in renumbers its neighbours as well, and the + // answer names only the night. The day is read back whole rather than guessed. + if (written?.movedAssignment) await tripActions.refreshDays(tripId) + await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' }) + await loadAccommodations() + updateRouteForDay(dayId) + setStopDraft(null) + return + } + const place = await tripActions.addPlace(tripId, { + name: poi.name, + lat: poi.lat, + lng: poi.lng, + address: poi.address || null, + website: poi.website || undefined, + phone: poi.phone || undefined, + osm_id: poi.osm_id, + stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel', + }) + if (place?.id) { + const plan = reanchorAfterInsert( + roadtripVias.byDay[dayId] ?? [], + position, + viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }), + ) + await tripActions.assignPlaceToDay(tripId, dayId, place.id, position) + await accommodationsApi.create(tripId, { + place_id: place.id, + start_day_id: dayId, + end_day_id: endDayId, + ...(checkIn ? { check_in: checkIn } : {}), + ...(checkOut ? { check_out: checkOut } : {}), + }) + await loadAccommodations() + await roadtripVias.reanchor(dayId, plan) + updateRouteForDay(dayId) + } + setStopDraft(null) + toast.success(t('roadtrip.stay.nightAdded')) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, setStopDraft]) + + /** + * Turns a stop on the drive into a pause, or back into a destination. + * + * The only difference between the two is `stop_type`, which decides whether the stop + * takes a number, counts in the day's total and appears in the printout. So this is one + * field on one place, and the rail redraws itself off the store the moment it lands. + */ + const setRoadtripStopKind = useCallback(async (placeId: number, kind: RoadtripStopType | null) => { + if (!can('place_edit', trip)) return + try { + await tripActions.updatePlace(tripId, placeId, { stop_type: kind }) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [tripId, tripActions, toast, t, can, trip]) + + /** + * How full THIS stop fills the tank, from the road trip rail. + * + * The same shape as the kind above and for the same reason: one field on one place, + * with the rail redrawing off the store the moment it lands. Null hands the stop back + * to whatever the traveller set as their own default, which is what every stop does + * until somebody has an opinion about one. + */ + const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => { + if (!can('place_edit', trip)) return + try { + await tripActions.updatePlace(tripId, placeId, { fill_percent: percent }) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [tripId, tripActions, toast, t, can, trip]) + + /** + * Moves a stop within its day, from the road trip rail. + * + * The rail reports only "this assignment, from here to there" and the full order is + * rebuilt here, from the day's COMPLETE assignment list rather than from what the rail + * shows. That matters: the rail hides stops without coordinates, and both + * `reorderAssignments` and the WebSocket handler rebuild the day's array purely from the + * ids they are given: anything left out would vanish from the store, for every session + * watching the trip. + * + * Within one day only. Moving between days stays in the day plan, where empty and + * one-stop days are visible and can be dropped onto; the rail leaves them out, so a day + * would disappear from under the cursor mid-gesture. + * + * No confirmation prompt for a stop with a pinned time, unlike the day plan: the rail + * recomputes the cascade immediately and marks a stop it can no longer reach in time. + * Showing the consequence is better than asking about it in advance. + */ + const reorderRoadtripStop = useCallback(async (dayId: number, assignmentId: number, toIndex: number) => { + if (!can('day_edit', trip)) return + const all = assignments[String(dayId)] ?? [] + const ordered = [...all].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + const fromIdx = ordered.findIndex(a => a.id === assignmentId) + if (fromIdx === -1) return + + // `toIndex` counts stops as the rail lists them; map it onto the full list, which may + // hold rows the rail never showed. + const visible = ordered.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number') + const target = visible[Math.min(Math.max(toIndex, 0), visible.length - 1)] + if (!target || target.id === assignmentId) return + const toIdx = ordered.findIndex(a => a.id === target.id) + if (toIdx === -1 || toIdx === fromIdx) return + + const next = [...ordered] + const [moved] = next.splice(fromIdx, 1) + next.splice(toIdx, 0, moved) + + // In the rail's own index space, which is the one the anchors are counted in. A stop + // without coordinates never entered that space, so moving it changes nothing there. + const fromVis = visible.findIndex(a => a.id === assignmentId) + const plan = fromVis === -1 + ? null + : reanchorAfterReorder(roadtripVias.byDay[dayId] ?? [], fromVis, toIndex, visible.length) + + try { + await tripActions.reorderAssignments(tripId, dayId, next.map(a => a.id)) + if (plan) await roadtripVias.reanchor(dayId, plan) + updateRouteForDay(dayId) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias]) + + /** + * The stop whose length is being set, or null while the dialog is closed. + * + * Held here rather than in the rail because the write goes through `tripActions`, and + * the rail is a list: putting a dialog's state inside a row means it dies whenever the + * list re-renders around it. + */ + const [stayDraft, setStayDraft] = useState(null) + + /** + * Whether the day ends at this stop, from BOTH the things that can end it. + * + * A stop carries an `end_day` flag, and a day can also be closed by a manual boundary + * filed against that stop (`to_assignment_id === null`). `setRoadtripEndDay` already + * knows about both and clears whichever is set, so a surface reading only the flag shows + * a day end as off, and the tap meant to switch it on deletes the boundary instead. The + * question is asked here once rather than answered again per surface. + */ + const roadtripEndsDayAt = useCallback( + (stop: RoadtripStop): boolean => + !!stop.endDay + || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId), + [dayBoundaries.boundaries], + ); + + /** + * Returns whether the day end actually moved. + * + * It reports rather than throws, because it shows its own toast and a second one from the + * caller would be the same news twice. A caller that flipped a switch optimistically has + * to hear about a refusal all the same, or it sits there showing a state the trip never + * reached: the phone sheet's catch was unreachable for exactly this reason. + */ + const setRoadtripEndDay = useCallback(async (stop: RoadtripStop): Promise => { + if (!dailyTimesActive || !can('day_edit', trip)) return false + try { + const manual = dayBoundaries.boundaries.find(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId) + if (manual) { + await dayBoundaries.save(manual.day_number, null) + if (!stop.endDay) return true + } + await tripActions.setAssignmentEndDay(tripId, stop.ownerDayId, stop.assignmentId, !stop.endDay) + return true + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + return false + } + }, [dailyTimesActive, can, trip, tripActions, tripId, toast, t, dayBoundaries.boundaries, dayBoundaries.save]) + + /** + * How long the traveller stays at one stop. + * + * Writes `places.duration_minutes`, which the schedule has read since it was written + * and which nothing in TREK has ever been able to set: the road trip is the only place + * the value means anything, so it is the only place that edits it. + * + * Zero rather than null to clear: the update statement folds a null into "leave it + * alone" (`COALESCE(?, duration_minutes)`), so a null could give a stop a stay but + * never take one away. The rail reads zero and absent as the same thing. + */ + const setRoadtripStay = useCallback(async (placeId: number, minutes: number) => { + if (!can('place_edit', trip)) return + try { + await tripActions.updatePlace(tripId, placeId, { duration_minutes: minutes }) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [tripId, tripActions, toast, t, can, trip]) + + /** + * Moves a stop onto another day, from the road trip rail. + * + * Split from `reorderRoadtripStop` because it is a different call with a different + * failure mode: `moveAssignment` writes to two days, and the rail has to be able to + * reach days it draws no drive for: a day with one stop or none is exactly what a + * stop gets moved onto when a leg turns out to be too long for one day. + * + * The target index counts the stops the rail shows on that day; an empty day takes + * position 0. + */ + const moveRoadtripStopToDay = useCallback(async ( + fromDayId: number, + assignmentId: number, + toDayId: number, + toIndex: number, + ) => { + if (!can('day_edit', trip)) return + if (fromDayId === toDayId) return + const target = (assignments[String(toDayId)] ?? []) + .slice() + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + const visible = target.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number') + // Map the rail's own count onto the day's full list, which may hold rows it hides. + const anchor = visible[Math.min(Math.max(toIndex, 0), Math.max(visible.length - 1, 0))] + const at = anchor ? target.findIndex(a => a.id === anchor.id) : target.length + + // Both days shift at once, and each needs its own correction: the stop leaves a gap + // on one side and opens one on the other. No geometry is measured for the arriving + // day: its roads are about to be different anyway, so there is nothing stable to + // measure a via against. + const fromStops = roadtripStopsOf(fromDayId) + const fromVis = fromStops.findIndex(a => a.id === assignmentId) + const fromPlan = fromVis === -1 + ? null + : reanchorAfterRemove(roadtripVias.byDay[fromDayId] ?? [], fromVis, fromStops.length) + const toVis = Math.min(Math.max(toIndex, 0), visible.length) + const toPlan = fromVis === -1 + ? null + : reanchorAfterInsert(roadtripVias.byDay[toDayId] ?? [], toVis, () => true) + + try { + await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, at < 0 ? target.length : at) + if (fromPlan) await roadtripVias.reanchor(fromDayId, fromPlan) + if (toPlan) await roadtripVias.reanchor(toDayId, toPlan) + updateRouteForDay(fromDayId) + updateRouteForDay(toDayId) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias, roadtripStopsOf]) + + /** + * How long the drive stands here, for every stop alike. + * + * A booked night used to be sent to the booking form instead, because its duration was + * read off the check-out and there was nothing here to set. The drive no longer reads + * a check-out at all: a night is a stop that takes as long as it takes, and asking how + * long is the same question at a hotel as at a viewpoint. + */ + const editRoadtripStay = useCallback((draft: NonNullable) => { + setStayDraft(draft) + }, []) + + return { + saveStopDraft, confirmStayRelease, saveStopDraftAsNight, setRoadtripStopKind, setRoadtripStopFill, + reorderRoadtripStop, stayDraft, setStayDraft, roadtripEndsDayAt, setRoadtripEndDay, setRoadtripStay, + moveRoadtripStopToDay, editRoadtripStay, + } +} + +export type RoadtripStopEdits = ReturnType diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index ca45aea11..50f537bef 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -25,7 +25,6 @@ import { useAuthStore } from '../../store/authStore' import { useResizablePanels } from '../../hooks/useResizablePanels' import { useTripWebSocket } from '../../hooks/useTripWebSocket' import { useRouteCalculation } from '../../hooks/useRouteCalculation' -import type { RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes' import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDayPoints' import { roadtripInsertion } from '../../components/Roadtrip/dayWindow' import { isStoredStop, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip' @@ -43,9 +42,8 @@ import { reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, - isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' + isServiceStopType, refuelStopTypeFor, type DryPoint } from '../../components/Roadtrip/roadtripModel' import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop' -import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal' import { inspectorStay } from '../../components/Roadtrip/stayReading' import { normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' @@ -57,7 +55,6 @@ import { usePluginStore } from '../../store/pluginStore' import type { Accommodation, Assignment, TripMember, Day, Place, Reservation } from '../../types' import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { useTileUrl } from '../../hooks/useTileUrl' -import { applyStayStops } from '../../store/stayStops' import { placesForDays, resolvePoolAssignmentId } from './tripPlannerModel' import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs' import { isRoutableReservation } from '../../utils/reservationRoutes' @@ -74,6 +71,7 @@ import { usePlannerDialogs } from './usePlannerDialogs' import { usePlannerMapView } from './usePlannerMapView' import { useMapPlaces } from './useMapPlaces' import { useRoadtripFeed } from './useRoadtripFeed' +import { useRoadtripStopEdits } from './useRoadtripStopEdits' import { useIsPhone } from '../../mobile/useIsPhone' /** @@ -695,352 +693,15 @@ export function useTripPlanner() { openAddPlaceFromPoi(poi, roadtripFeedActive ? target?.dayId ?? roadtripDayId : undefined) }, [openAddPlaceFromPoi, roadtripFeedActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip, setStopDraft]) - /** - * Saves a corridor hit as a stop: the place itself, then its position in the day. - * - * `stop_type` is what makes it a fuel stop rather than a place that happens to sell - * fuel — the road-trip kinds are their own dimension, deliberately not one of the - * traveller's editable categories, so the palette and the meaning stay put. - */ - const saveStopDraft = useCallback(async ( - { stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }, - { releaseStay = false }: { releaseStay?: boolean } = {}, - ) => { - if (!stopDraft) return - const { poi, dayId, position } = stopDraft - const accommodationId = stopDraft.editing?.accommodationId - // Turning a booked night into a pause deletes the booking, and with it the - // reservation and the expense the server keeps against it. The popup stays open - // behind the question, so a no leaves the traveller exactly where they were. - if (accommodationId && !releaseStay) { - const stay = tripAccommodations.find(s => s.id === accommodationId) - const booking = reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === accommodationId)?.title - ?? stay?.reservation_title ?? null - setStayRelease({ stop: { stopType, dwellMinutes }, name: poi.name, booking }) - return - } - try { - if (stopDraft.editing) { - await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes }) - if (accommodationId) { - // The night is what was switched off, not the stop: it stays where it is - // in the drive and becomes an ordinary pause. - applyStayStops(await accommodationsApi.delete(tripId, accommodationId, { keepStop: true })) - await loadAccommodations() - } - updateRouteForDay(dayId) - setStopDraft(null) - return - } - const place = await tripActions.addPlace(tripId, { - name: poi.name, - lat: poi.lat, - lng: poi.lng, - address: poi.address || null, - website: poi.website || undefined, - phone: poi.phone || undefined, - osm_id: poi.osm_id, - duration_minutes: dwellMinutes, - stop_type: stopType, - }) - if (place?.id) { - // Worked out BEFORE the stop lands, against the day as it stands and the road as - // it is currently driven — once the list has shifted there is no record of which - // leg each via was drawn for. - const plan = reanchorAfterInsert( - roadtripVias.byDay[dayId] ?? [], - position, - viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }), - ) - await tripActions.assignPlaceToDay(tripId, dayId, place.id, position) - // Awaited before the day re-routes: the routing effect reads the anchors against - // the new stop list, so a correction landing after it would draw the wrong road - // first and the right one a moment later. - await roadtripVias.reanchor(dayId, plan) - updateRouteForDay(dayId) - } - setStopDraft(null) - toast.success(t('trip.toast.placeAdded')) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, tripAccommodations, reservations, setStayRelease, setStopDraft]) - - /** The yes to the question above: the same save, this time allowed to drop the night. */ - const confirmStayRelease = useCallback(async () => { - const pending = stayRelease - setStayRelease(null) - if (pending) await saveStopDraft(pending.stop, { releaseStay: true }) - }, [stayRelease, saveStopDraft, setStayRelease]) - - const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: { - endDayId: number - checkIn: string - checkOut: string - }) => { - if (!stopDraft) return - const { poi, dayId, position } = stopDraft - try { - if (stopDraft.editing) { - const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null } - // The answer carries the day stop the booking moved or added. The socket - // deliberately skips the session that sent the request, so without folding - // it in the one person whose rail still shows the night at its old place in - // the chain is the one who just moved its check-in. - const written = stopDraft.editing.accommodationId - ? await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking) - : await accommodationsApi.create(tripId, booking) - applyStayStops(written) - // A night seated by a new check-in renumbers its neighbours as well, and the - // answer names only the night. The day is read back whole rather than guessed. - if (written?.movedAssignment) await tripActions.refreshDays(tripId) - await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' }) - await loadAccommodations() - updateRouteForDay(dayId) - setStopDraft(null) - return - } - const place = await tripActions.addPlace(tripId, { - name: poi.name, - lat: poi.lat, - lng: poi.lng, - address: poi.address || null, - website: poi.website || undefined, - phone: poi.phone || undefined, - osm_id: poi.osm_id, - stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel', - }) - if (place?.id) { - const plan = reanchorAfterInsert( - roadtripVias.byDay[dayId] ?? [], - position, - viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }), - ) - await tripActions.assignPlaceToDay(tripId, dayId, place.id, position) - await accommodationsApi.create(tripId, { - place_id: place.id, - start_day_id: dayId, - end_day_id: endDayId, - ...(checkIn ? { check_in: checkIn } : {}), - ...(checkOut ? { check_out: checkOut } : {}), - }) - await loadAccommodations() - await roadtripVias.reanchor(dayId, plan) - updateRouteForDay(dayId) - } - setStopDraft(null) - toast.success(t('roadtrip.stay.nightAdded')) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, setStopDraft]) - - /** - * Turns a stop on the drive into a pause, or back into a destination. - * - * The only difference between the two is `stop_type`, which decides whether the stop - * takes a number, counts in the day's total and appears in the printout. So this is one - * field on one place, and the rail redraws itself off the store the moment it lands. - */ - const setRoadtripStopKind = useCallback(async (placeId: number, kind: RoadtripStopType | null) => { - if (!can('place_edit', trip)) return - try { - await tripActions.updatePlace(tripId, placeId, { stop_type: kind }) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [tripId, tripActions, toast, t, can, trip]) - - /** - * How full THIS stop fills the tank, from the road trip rail. - * - * The same shape as the kind above and for the same reason: one field on one place, - * with the rail redrawing off the store the moment it lands. Null hands the stop back - * to whatever the traveller set as their own default, which is what every stop does - * until somebody has an opinion about one. - */ - const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => { - if (!can('place_edit', trip)) return - try { - await tripActions.updatePlace(tripId, placeId, { fill_percent: percent }) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [tripId, tripActions, toast, t, can, trip]) - - /** - * Moves a stop within its day, from the road trip rail. - * - * The rail reports only "this assignment, from here to there" and the full order is - * rebuilt here, from the day's COMPLETE assignment list rather than from what the rail - * shows. That matters: the rail hides stops without coordinates, and both - * `reorderAssignments` and the WebSocket handler rebuild the day's array purely from the - * ids they are given — anything left out would vanish from the store, for every session - * watching the trip. - * - * Within one day only. Moving between days stays in the day plan, where empty and - * one-stop days are visible and can be dropped onto; the rail leaves them out, so a day - * would disappear from under the cursor mid-gesture. - * - * No confirmation prompt for a stop with a pinned time, unlike the day plan: the rail - * recomputes the cascade immediately and marks a stop it can no longer reach in time. - * Showing the consequence is better than asking about it in advance. - */ - const reorderRoadtripStop = useCallback(async (dayId: number, assignmentId: number, toIndex: number) => { - if (!can('day_edit', trip)) return - const all = assignments[String(dayId)] ?? [] - const ordered = [...all].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - const fromIdx = ordered.findIndex(a => a.id === assignmentId) - if (fromIdx === -1) return - - // `toIndex` counts stops as the rail lists them; map it onto the full list, which may - // hold rows the rail never showed. - const visible = ordered.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number') - const target = visible[Math.min(Math.max(toIndex, 0), visible.length - 1)] - if (!target || target.id === assignmentId) return - const toIdx = ordered.findIndex(a => a.id === target.id) - if (toIdx === -1 || toIdx === fromIdx) return - - const next = [...ordered] - const [moved] = next.splice(fromIdx, 1) - next.splice(toIdx, 0, moved) - - // In the rail's own index space, which is the one the anchors are counted in. A stop - // without coordinates never entered that space, so moving it changes nothing there. - const fromVis = visible.findIndex(a => a.id === assignmentId) - const plan = fromVis === -1 - ? null - : reanchorAfterReorder(roadtripVias.byDay[dayId] ?? [], fromVis, toIndex, visible.length) - - try { - await tripActions.reorderAssignments(tripId, dayId, next.map(a => a.id)) - if (plan) await roadtripVias.reanchor(dayId, plan) - updateRouteForDay(dayId) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias]) - - /** - * The stop whose length is being set, or null while the dialog is closed. - * - * Held here rather than in the rail because the write goes through `tripActions`, and - * the rail is a list — putting a dialog's state inside a row means it dies whenever the - * list re-renders around it. - */ - const [stayDraft, setStayDraft] = useState(null) - - /** - * Whether the day ends at this stop, from BOTH the things that can end it. - * - * A stop carries an `end_day` flag, and a day can also be closed by a manual boundary - * filed against that stop (`to_assignment_id === null`). `setRoadtripEndDay` already - * knows about both and clears whichever is set, so a surface reading only the flag shows - * a day end as off, and the tap meant to switch it on deletes the boundary instead. The - * question is asked here once rather than answered again per surface. - */ - const roadtripEndsDayAt = useCallback( - (stop: RoadtripStop): boolean => - !!stop.endDay - || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId), - [dayBoundaries.boundaries], - ); - - /** - * Returns whether the day end actually moved. - * - * It reports rather than throws, because it shows its own toast and a second one from the - * caller would be the same news twice. A caller that flipped a switch optimistically has - * to hear about a refusal all the same, or it sits there showing a state the trip never - * reached: the phone sheet's catch was unreachable for exactly this reason. - */ - const setRoadtripEndDay = useCallback(async (stop: RoadtripStop): Promise => { - if (!dailyTimesActive || !can('day_edit', trip)) return false - try { - const manual = dayBoundaries.boundaries.find(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId) - if (manual) { - await dayBoundaries.save(manual.day_number, null) - if (!stop.endDay) return true - } - await tripActions.setAssignmentEndDay(tripId, stop.ownerDayId, stop.assignmentId, !stop.endDay) - return true - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - return false - } - }, [dailyTimesActive, can, trip, tripActions, tripId, toast, t, dayBoundaries.boundaries, dayBoundaries.save]) - - /** - * How long the traveller stays at one stop. - * - * Writes `places.duration_minutes`, which the schedule has read since it was written - * and which nothing in TREK has ever been able to set — the road trip is the only place - * the value means anything, so it is the only place that edits it. - * - * Zero rather than null to clear: the update statement folds a null into "leave it - * alone" (`COALESCE(?, duration_minutes)`), so a null could give a stop a stay but - * never take one away. The rail reads zero and absent as the same thing. - */ - const setRoadtripStay = useCallback(async (placeId: number, minutes: number) => { - if (!can('place_edit', trip)) return - try { - await tripActions.updatePlace(tripId, placeId, { duration_minutes: minutes }) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [tripId, tripActions, toast, t, can, trip]) - - /** - * Moves a stop onto another day, from the road trip rail. - * - * Split from `reorderRoadtripStop` because it is a different call with a different - * failure mode: `moveAssignment` writes to two days, and the rail has to be able to - * reach days it draws no drive for — a day with one stop or none is exactly what a - * stop gets moved onto when a leg turns out to be too long for one day. - * - * The target index counts the stops the rail shows on that day; an empty day takes - * position 0. - */ - const moveRoadtripStopToDay = useCallback(async ( - fromDayId: number, - assignmentId: number, - toDayId: number, - toIndex: number, - ) => { - if (!can('day_edit', trip)) return - if (fromDayId === toDayId) return - const target = (assignments[String(toDayId)] ?? []) - .slice() - .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - const visible = target.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number') - // Map the rail's own count onto the day's full list, which may hold rows it hides. - const anchor = visible[Math.min(Math.max(toIndex, 0), Math.max(visible.length - 1, 0))] - const at = anchor ? target.findIndex(a => a.id === anchor.id) : target.length - - // Both days shift at once, and each needs its own correction: the stop leaves a gap - // on one side and opens one on the other. No geometry is measured for the arriving - // day — its roads are about to be different anyway, so there is nothing stable to - // measure a via against. - const fromStops = roadtripStopsOf(fromDayId) - const fromVis = fromStops.findIndex(a => a.id === assignmentId) - const fromPlan = fromVis === -1 - ? null - : reanchorAfterRemove(roadtripVias.byDay[fromDayId] ?? [], fromVis, fromStops.length) - const toVis = Math.min(Math.max(toIndex, 0), visible.length) - const toPlan = fromVis === -1 - ? null - : reanchorAfterInsert(roadtripVias.byDay[toDayId] ?? [], toVis, () => true) - - try { - await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, at < 0 ? target.length : at) - if (fromPlan) await roadtripVias.reanchor(fromDayId, fromPlan) - if (toPlan) await roadtripVias.reanchor(toDayId, toPlan) - updateRouteForDay(fromDayId) - updateRouteForDay(toDayId) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias, roadtripStopsOf]) + const { + stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndsDayAt, setRoadtripEndDay, + saveStopDraft, saveStopDraftAsNight, confirmStayRelease, reorderRoadtripStop, moveRoadtripStopToDay, + setRoadtripStopKind, setRoadtripStopFill, + } = useRoadtripStopEdits({ + tripId, trip, can, tripActions, toast, t, stopDraft, setStopDraft, stayRelease, setStayRelease, + assignments, tripAccommodations, reservations, loadAccommodations, updateRouteForDay, + roadtripVias, roadtripStopsOf, viaLiesBefore, dayBoundaries, dailyTimesActive, + }) const routeAlternatives = useRouteAlternatives() /** @@ -1820,18 +1481,6 @@ export function useTripPlanner() { setShowPlaceForm(true) }, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setServiceStopForm, setShowPlaceForm, setStopDraft]) - /** - * How long the drive stands here, for every stop alike. - * - * A booked night used to be sent to the booking form instead, because its duration was - * read off the check-out and there was nothing here to set. The drive no longer reads - * a check-out at all: a night is a stop that takes as long as it takes, and asking how - * long is the same question at a hotel as at a viewpoint. - */ - const editRoadtripStay = useCallback((draft: NonNullable) => { - setStayDraft(draft) - }, []) - const handleDeletePlace = useCallback( (placeId) => { if (!can('place_edit', trip)) return