From 9681fdf321e972ddfd2f00b528acc5779a4ac4ca Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 02:04:54 +0000 Subject: [PATCH] refactor(client): the edits to one stop on the drive move into useRoadtripStopEdits Saving the stop popup as a pause or as a night, the stay release that asks before a booked night is dropped, the kind, fill and stay of a stop, moving a stop within its day or onto another one from the rail, ending the day at a stop, and the stay dialog with the call that opens it move out of useTripPlanner into useRoadtripStopEdits. None of it runs an effect, and useTripPlanner calls the hook where the stop edits used to sit, so the planner returns the same object and behaves exactly as before. --- .../tripPlanner/useRoadtripStopEdits.test.ts | 488 ++++++++++++++++++ .../pages/tripPlanner/useRoadtripStopEdits.ts | 406 +++++++++++++++ .../src/pages/tripPlanner/useTripPlanner.ts | 373 +------------ 3 files changed, 905 insertions(+), 362 deletions(-) create mode 100644 client/src/pages/tripPlanner/useRoadtripStopEdits.test.ts create mode 100644 client/src/pages/tripPlanner/useRoadtripStopEdits.ts 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