From 7e6af9c8fc4354643d69457923bc5afc9129e6c4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 03:03:06 +0000 Subject: [PATCH] refactor(client): the ways into the place form and the stop popup move into usePlaceFormOpeners The right click on the map, the POI marker and the hit on the drive with the check-out days a night can end on, the service stop added by hand with the legs it can go on, the popup handing over to the full form with its duplicate check, and the place editor move out of useTripPlanner into usePlaceFormOpeners. None of it runs an effect, and useTripPlanner calls the hook right after the place edits, where the place editor used to sit, so the planner returns the same object and behaves exactly as before. The note on adding a POI with its day now sits directly above the handler it describes. --- .../tripPlanner/usePlaceFormOpeners.test.ts | 382 ++++++++++++++++++ .../pages/tripPlanner/usePlaceFormOpeners.ts | 324 +++++++++++++++ .../src/pages/tripPlanner/useTripPlanner.ts | 282 +------------ 3 files changed, 718 insertions(+), 270 deletions(-) create mode 100644 client/src/pages/tripPlanner/usePlaceFormOpeners.test.ts create mode 100644 client/src/pages/tripPlanner/usePlaceFormOpeners.ts diff --git a/client/src/pages/tripPlanner/usePlaceFormOpeners.test.ts b/client/src/pages/tripPlanner/usePlaceFormOpeners.test.ts new file mode 100644 index 000000000..1791a6470 --- /dev/null +++ b/client/src/pages/tripPlanner/usePlaceFormOpeners.test.ts @@ -0,0 +1,382 @@ +// FE-TP-OPENERS-001 to FE-TP-OPENERS-015 +// +// Every way into the place form or the stop popup, driven straight through +// usePlaceFormOpeners. The form's fields and the popup's draft are real state in the +// harness, so each case reads what the form or the popup would open with. +import { useState } from 'react' +import { act, renderHook } from '@testing-library/react' +import type { RoadtripStopType } from '@trek/shared' +import { mapsApi } from '../../api/client' +import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' +import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' +import type { RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes' +import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories' +import type { Accommodation, AssignmentsMap, Place } from '../../types' +import type { Can } from './plannerTypes' +import type { PlannerDialogs } from './usePlannerDialogs' +import type { RoadtripFeed } from './useRoadtripFeed' +import { usePlaceFormOpeners } from './usePlaceFormOpeners' + +type Options = Parameters[0] +type Fixed = Omit +type Card = RoadtripFeed['roadtripRoutes']['days'][number] + +let allowed = true +const can: Can = vi.fn(() => allowed) +const trip = buildTrip({ id: 42 }) + +// Day 10 comes first in travel order although it is listed second; day 12 has no number. +const days = [ + buildDay({ id: 11, day_number: 2, date: '2026-05-02' }), + buildDay({ id: 10, day_number: 1, date: '2026-05-01' }), + buildDay({ id: 12, date: undefined }), +] +const lake = buildPlace({ id: 1, name: 'Lake', osm_id: 'node/1' }) +const pump = buildPlace({ id: 2, name: 'Pump', stop_type: 'fuel', lat: 50, lng: 10.5, osm_id: 'node/2' }) +const hotel = buildPlace({ id: 3, name: 'Inn', lat: 50, lng: 11, duration_minutes: undefined }) +const inn = buildPlace({ id: 4, name: 'Second Inn', lat: 51, lng: 11 }) +const noCoords = buildPlace({ id: 5, name: 'Note', stop_type: 'fuel', lat: null, lng: null }) +const camp = buildPlace({ id: 7, name: 'Camp', stop_type: 'campsite', lat: 50, lng: 10.8 }) +const assignments: AssignmentsMap = { + 10: [buildAssignment({ id: 101, place: lake, order_index: 0 }), buildAssignment({ id: 102, place: pump, order_index: 2 })], + 14: [buildAssignment({ id: 141, place: hotel, order_index: undefined })], + 11: [buildAssignment({ id: 111, place: inn, order_index: 1 })], + 12: [buildAssignment({ id: 121, place: camp, order_index: 0 })], +} +const stay = (over: Partial) => ({ id: 70, trip_id: 42, start_day_id: 14, end_day_id: 12, ...over }) as Accommodation + +const stop = (over: Partial) => ({ name: 'Stop', lat: 50, lng: 10, stopType: null, ...over }) as RoadtripStop +/** Day 10's card: a lake, the pump, and a stop at the end, driven east from 10° to 11°. */ +const card = (): Card => ({ + dayId: 10, dayNumber: 1, + stops: [ + stop({ assignmentId: 101, ownerDayId: 10, ownerIndex: 0, name: 'Lake', lng: 10 }), + stop({ assignmentId: 102, ownerDayId: 10, ownerIndex: 1, name: 'Pump', lng: 10.5 }), + stop({ assignmentId: 103, ownerDayId: 10, ownerIndex: 2, name: 'Town', lng: 11 }), + ], + legs: [{ mode: 'driving', distance: 100_000, duration: 3_600 }, { mode: 'driving', distance: 100_000, duration: 3_600 }], + schedule: { entries: [{ arrival: '09:00', departure: '09:30' }, { arrival: '11:15', departure: '11:45' }, { arrival: '13:00' }] }, + geometry: [[50, 10], [50, 11]], +}) as unknown as Card + +const hit = (over: Partial = {}): CorridorPoi => ({ + osm_id: 'node/9', name: 'Rasthof', lat: 50, lng: 10.25, category: 'fuel', poi_type: 'fuel', + address: 'A7', website: 'rasthof.example', phone: '123', opening_hours: null, cuisine: null, + source: 'osm', offRouteKm: 0.2, alongKm: 17, ...over, +}) + +let corridor: { day: { dayId: number; dayNumber: number } | null; insertIndexFor: ReturnType } +let fixed: Fixed + +function renderOpeners(over: Partial = {}, draft: RoadtripStopDraft | null = null) { + const options = { ...fixed, ...over } + return renderHook(() => { + const [prefillCoords, setPrefillCoords] = useState(null) + const [editingPlace, setEditingPlace] = useState(null) + const [editingAssignmentId, setEditingAssignmentId] = useState(null) + const [placeFormDayId, setPlaceFormDayId] = useState(null) + const [placeFormPosition, setPlaceFormPosition] = useState(null) + const [showPlaceForm, setShowPlaceForm] = useState(false) + const [serviceStopForm, setServiceStopForm] = useState(false) + const [serviceStopKind, setServiceStopKind] = useState(null) + const [stopDraft, setStopDraft] = useState(draft) + const openers = usePlaceFormOpeners({ + ...options, stopDraft, setStopDraft, serviceStopForm, setServiceStopForm, serviceStopKind, setServiceStopKind, + setPrefillCoords, setEditingPlace, setEditingAssignmentId, setPlaceFormDayId, setPlaceFormPosition, setShowPlaceForm, + }) + const form = { prefillCoords, editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, showPlaceForm, serviceStopForm, serviceStopKind } + return { ...openers, form, stopDraft, setPrefillCoords } + }) +} + +const rightClick = (lat: number, lng: number) => ({ originalEvent: { preventDefault: vi.fn() }, latlng: { lat, lng } }) + +beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + allowed = true + corridor = { day: { dayId: 10, dayNumber: 1 }, insertIndexFor: vi.fn(() => 1) } + fixed = { + trip, can, placeLang: 'de', isMobile: false, days, places: [lake, pump, hotel, inn, noCoords, camp], assignments, + tripAccommodations: [], handlePlaceClick: vi.fn(), isTourPlace: id => id === 1, + roadtripActive: false, roadtripFeedActive: false, + roadtripRoutes: { days: [card()] } as unknown as RoadtripFeed['roadtripRoutes'], + roadtripCorridor: corridor as unknown as RoadtripFeed['roadtripCorridor'], + manualStopTargetFor: vi.fn(() => null), + } +}) + +describe('usePlaceFormOpeners', () => { + it('FE-TP-OPENERS-001: a right click opens the form on the spot and names it once the address is known', async () => { + const reverse = vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Cafe', address: null } as never) + const { result } = renderOpeners() + const event = rightClick(50, 10) + + await act(async () => { await result.current.handleMapContextMenu(event, 10) }) + + expect(event.originalEvent.preventDefault).toHaveBeenCalled() + expect(reverse).toHaveBeenCalledWith(50, 10, 'de') + expect(result.current.form).toMatchObject({ + prefillCoords: { lat: 50, lng: 10, name: 'Cafe', address: '' }, + editingPlace: null, editingAssignmentId: null, placeFormDayId: 10, serviceStopForm: false, showPlaceForm: true, + }) + }) + + it('FE-TP-OPENERS-002: a place the map cannot name keeps its bare coordinates, and a closed form stays closed', async () => { + const reverse = vi.spyOn(mapsApi, 'reverse').mockResolvedValueOnce({ name: null, address: null } as never) + const { result } = renderOpeners() + + await act(async () => { await result.current.handleMapContextMenu(rightClick(50, 10)) }) + expect(result.current.form.prefillCoords).toEqual({ lat: 50, lng: 10 }) + expect(result.current.form.placeFormDayId).toBeNull() + + let answer: (value: unknown) => void = () => undefined + reverse.mockReturnValueOnce(new Promise(resolve => { answer = resolve }) as never) + let pending: Promise = Promise.resolve() + act(() => { pending = result.current.handleMapContextMenu(rightClick(51, 11)) }) + act(() => { result.current.setPrefillCoords(null) }) + await act(async () => { answer({ name: 'Late', address: 'Somewhere' }); await pending }) + expect(result.current.form.prefillCoords).toBeNull() + + reverse.mockRejectedValueOnce(new Error('offline')) + await act(async () => { await result.current.handleMapContextMenu(rightClick(52, 12)) }) + expect(result.current.form.prefillCoords).toEqual({ lat: 52, lng: 12 }) + + reverse.mockResolvedValueOnce({ name: null, address: 'Main St 1' } as never) + await act(async () => { await result.current.handleMapContextMenu(rightClick(53, 13)) }) + expect(result.current.form.prefillCoords).toEqual({ lat: 53, lng: 13, name: '', address: 'Main St 1' }) + }) + + it('FE-TP-OPENERS-003: a reader opens neither the form nor the popup from the map', async () => { + allowed = false + const reverse = vi.spyOn(mapsApi, 'reverse') + const { result } = renderOpeners({ roadtripFeedActive: true }) + + await act(async () => { await result.current.handleMapContextMenu(rightClick(50, 10)) }) + act(() => { result.current.openAddPlaceFromPoi(hit()) }) + act(() => { result.current.handlePoiClick(hit()) }) + act(() => { result.current.openManualRoadtripStop('fuel') }) + act(() => { result.current.openPlaceEditor(lake) }) + + expect(reverse).not.toHaveBeenCalled() + expect(result.current.form.showPlaceForm).toBe(false) + expect(result.current.stopDraft).toBeNull() + }) + + it('FE-TP-OPENERS-004: a POI fills the form with what it knows, the stop it was meant as included', () => { + const { result } = renderOpeners() + + act(() => { result.current.openAddPlaceFromPoi(hit({ address: null, website: 'javascript:alert(1)', phone: null, poi_type: null }), 10, 3, { stopType: 'fuel', dwellMinutes: 15 }) }) + + expect(result.current.form).toMatchObject({ + prefillCoords: { + lat: 50, lng: 10.25, name: 'Rasthof', address: '', website: undefined, phone: undefined, + osm_id: 'node/9', category: 'fuel', stop_type: 'fuel', duration_minutes: 15, + }, + placeFormDayId: 10, placeFormPosition: 3, showPlaceForm: true, serviceStopForm: false, + }) + + act(() => { result.current.openAddPlaceFromPoi(hit({ category: null, poi_type: 'museum' })) }) + expect(result.current.form.prefillCoords).toMatchObject({ category: 'museum', stop_type: null, website: 'https://rasthof.example', phone: '123' }) + expect(result.current.form.placeFormDayId).toBeNull() + expect(result.current.form.placeFormPosition).toBeNull() + }) + + it('FE-TP-OPENERS-005: a hit on the drive opens the popup at its place in the chain, with the time it is reached', () => { + const { result } = renderOpeners({ roadtripFeedActive: true }) + + act(() => { result.current.handlePoiClick(hit()) }) + + expect(corridor.insertIndexFor).toHaveBeenCalledWith(expect.objectContaining({ alongKm: 17 })) + expect(result.current.stopDraft).toEqual({ poi: hit(), arrivalTime: '09:40', dayId: 10, position: 1, dayNumber: 1 }) + expect(result.current.form.showPlaceForm).toBe(false) + }) + + it('FE-TP-OPENERS-006: a hit somebody could sleep at offers the days after it, in travel order', () => { + const { result } = renderOpeners({ roadtripFeedActive: true }) + + act(() => { result.current.handlePoiClick(hit({ category: 'hotel' })) }) + + expect(result.current.stopDraft?.overnight).toEqual({ + days: [{ id: 10, number: 1, date: '2026-05-01' }, { id: 11, number: 2, date: '2026-05-02' }, { id: 12, number: 0, date: null }], + defaultEndDayId: 11, + }) + }) + + it('FE-TP-OPENERS-007: a night on a day the trip does not list, or on a trip with no days, still has an end', () => { + corridor.insertIndexFor.mockReturnValue(0) + const elsewhere = { ...card(), stops: [stop({ assignmentId: 991, ownerDayId: 99, ownerIndex: 0 })] } as Card + const routes = { days: [elsewhere] } as unknown as RoadtripFeed['roadtripRoutes'] + + const listed = renderOpeners({ roadtripFeedActive: true, roadtripRoutes: routes }) + act(() => { listed.result.current.handlePoiClick(hit({ category: 'campsite' })) }) + expect(listed.result.current.stopDraft?.overnight).toMatchObject({ defaultEndDayId: 11 }) + expect(listed.result.current.stopDraft?.overnight?.days).toHaveLength(3) + listed.unmount() + + const empty = renderOpeners({ roadtripFeedActive: true, roadtripRoutes: routes, days: [] }) + act(() => { empty.result.current.handlePoiClick(hit({ category: 'hotel' })) }) + expect(empty.result.current.stopDraft?.overnight).toEqual({ days: [], defaultEndDayId: 99 }) + }) + + it('FE-TP-OPENERS-008: a hit on a day the rail draws no card for opens nothing', () => { + const { result } = renderOpeners({ roadtripFeedActive: true, roadtripRoutes: { days: [] } as unknown as RoadtripFeed['roadtripRoutes'] }) + + act(() => { result.current.handlePoiClick(hit()) }) + + expect(result.current.stopDraft).toBeNull() + expect(result.current.form.showPlaceForm).toBe(false) + }) + + it('FE-TP-OPENERS-009: a plain POI goes to the form, onto the searched day while the drive is fed', () => { + const plain = { lat: 50, lng: 10.2, name: 'Museum', address: null, website: null, phone: null, osm_id: 'node/5' } + + const fed = renderOpeners({ roadtripFeedActive: true }) + act(() => { fed.result.current.handlePoiClick(plain) }) + expect(fed.result.current.form).toMatchObject({ placeFormDayId: 10, showPlaceForm: true }) + fed.unmount() + + const undrawn = renderOpeners({ roadtripFeedActive: true, roadtripRoutes: { days: [] } as unknown as RoadtripFeed['roadtripRoutes'] }) + act(() => { undrawn.result.current.handlePoiClick(plain) }) + expect(undrawn.result.current.form.placeFormDayId).toBe(10) + undrawn.unmount() + + // Outside the drive even a corridor hit is just a place, and the day is left to the traveller. + const off = renderOpeners() + act(() => { off.result.current.handlePoiClick(hit()) }) + expect(off.result.current.stopDraft).toBeNull() + expect(off.result.current.form).toMatchObject({ placeFormDayId: null, showPlaceForm: true }) + }) + + it('FE-TP-OPENERS-010: a service stop by hand opens an empty form, and the legs it can go on come with it', () => { + const targetFor = vi.fn(() => null) + const { result } = renderOpeners({ roadtripCorridor: { ...corridor, day: card() } as unknown as RoadtripFeed['roadtripCorridor'], manualStopTargetFor: targetFor }) + expect(result.current.serviceStopMode).toBeNull() + + act(() => { result.current.openManualRoadtripStop('charging') }) + + expect(result.current.form).toMatchObject({ + prefillCoords: null, editingPlace: null, editingAssignmentId: null, placeFormDayId: null, placeFormPosition: null, + serviceStopKind: 'charging', serviceStopForm: true, showPlaceForm: true, + }) + const mode = result.current.serviceStopMode! + expect(mode.defaultKind).toBe('charging') + expect(mode.days).toHaveLength(1) + expect(mode.days[0]).toMatchObject({ dayId: 10, dayNumber: 1, stops: ['Lake', 'Pump', 'Town'] }) + expect(mode.days[0].legLines).toHaveLength(2) + expect(mode.appendDay).toEqual({ dayId: 10, dayNumber: 1, position: 3 }) + expect(mode.targetFor).toBe(targetFor) + }) + + it('FE-TP-OPENERS-011: without a day on the panel the service stop has no end to append to, and an unrouted day is left out', () => { + const unrouted = { ...card(), dayId: 11, geometry: [[50, 10]] } as Card + const { result } = renderOpeners({ + roadtripCorridor: { ...corridor, day: null } as unknown as RoadtripFeed['roadtripCorridor'], + roadtripRoutes: { days: [card(), unrouted] } as unknown as RoadtripFeed['roadtripRoutes'], + }) + + act(() => { result.current.openManualRoadtripStop() }) + + expect(result.current.form.serviceStopKind).toBeNull() + expect(result.current.serviceStopMode?.appendDay).toBeNull() + expect(result.current.serviceStopMode?.days.map(day => day.dayId)).toEqual([10]) + }) + + it('FE-TP-OPENERS-012: the popup hands over to the full form, for a new stop and for one being edited', () => { + const draft = { poi: hit(), dayId: 10, position: 2, dayNumber: 1 } + + const fresh = renderOpeners({}, draft) + expect(fresh.result.current.stopDraftDuplicate).toBeNull() + act(() => { fresh.result.current.stopDraftToForm({ stopType: 'fuel', dwellMinutes: 20 }) }) + expect(fresh.result.current.stopDraft).toBeNull() + expect(fresh.result.current.form).toMatchObject({ + prefillCoords: { name: 'Rasthof', stop_type: 'fuel', duration_minutes: 20 }, placeFormDayId: 10, placeFormPosition: 2, showPlaceForm: true, + }) + // Nothing left to hand over. + act(() => { fresh.result.current.stopDraftToForm() }) + expect(fresh.result.current.form.showPlaceForm).toBe(true) + fresh.unmount() + + const editing = { ...draft, editing: { placeId: 2, dwellMinutes: 15, stopType: 'fuel' as const } } + const edit = renderOpeners({}, editing) + expect(edit.result.current.stopDraftDuplicate).toBeNull() + act(() => { edit.result.current.stopDraftToForm() }) + expect(edit.result.current.form).toMatchObject({ editingPlace: pump, editingAssignmentId: 102, showPlaceForm: true, prefillCoords: null }) + edit.unmount() + + // A stop deleted meanwhile only closes the popup. + const gone = renderOpeners({}, { ...draft, editing: { placeId: 77, dwellMinutes: 15, stopType: null } }) + act(() => { gone.result.current.stopDraftToForm() }) + expect(gone.result.current.stopDraft).toBeNull() + expect(gone.result.current.form.showPlaceForm).toBe(false) + }) + + it('FE-TP-OPENERS-013: the popup names a place on the trip that came from the same map object', () => { + const same = renderOpeners({}, { poi: hit({ osm_id: 'node/2' }), dayId: 10, position: 2, dayNumber: 1 }) + expect(same.result.current.stopDraftDuplicate).toBe('Pump') + same.unmount() + + const none = renderOpeners({}, { poi: hit({ osm_id: '' }), dayId: 10, position: 2, dayNumber: 1 }) + expect(none.result.current.stopDraftDuplicate).toBeNull() + }) + + it('FE-TP-OPENERS-014: the place editor opens the popup for a stop or a night of the drive, and the form otherwise', () => { + const tripAccommodations = [ + stay({ id: 70, place_id: 3, start_day_id: 14, end_day_id: 12, check_in: '15:00', check_out: '10:00' }), + // Booked on another day than the visit being opened. + stay({ id: 71, place_id: 4, start_day_id: 10, end_day_id: 11 }), + ] + const { result } = renderOpeners({ roadtripActive: true, tripAccommodations }) + + act(() => { result.current.openPlaceEditor(pump) }) + expect(result.current.stopDraft).toMatchObject({ + poi: { osm_id: 'node/2', name: 'Pump', category: 'fuel', source: 'trek' }, + arrivalTime: '11:15', dayId: 10, dayNumber: 1, position: 2, + editing: { placeId: 2, stopType: 'fuel', dwellMinutes: 60, accommodationId: undefined, checkIn: '', checkOut: '' }, + }) + expect(result.current.stopDraft?.overnight).toBeUndefined() + + // A night on a day the trip no longer lists: no number, no time, and the stay's own end. + act(() => { result.current.openPlaceEditor(hotel) }) + expect(result.current.stopDraft).toMatchObject({ + poi: { category: 'hotel', osm_id: '' }, arrivalTime: null, dayId: 14, dayNumber: 0, position: 0, + editing: { placeId: 3, stopType: 'hotel', dwellMinutes: 30, accommodationId: 70, checkIn: '15:00', checkOut: '10:00' }, + overnight: { defaultEndDayId: 12 }, + }) + + act(() => { result.current.openPlaceEditor(inn, 111) }) + expect(result.current.stopDraft).toMatchObject({ dayId: 11, position: 1, editing: { stopType: null, accommodationId: undefined } }) + expect(result.current.stopDraft?.overnight).toBeUndefined() + expect(result.current.form.showPlaceForm).toBe(false) + + // A campsite with no night booked yet can still become one, ending the day after. + act(() => { result.current.openPlaceEditor(camp) }) + expect(result.current.stopDraft).toMatchObject({ dayId: 12, dayNumber: 0, editing: { stopType: 'campsite' }, overnight: { defaultEndDayId: 12 } }) + + // No visit to open, or no coordinates to put on the drive: the full form. + act(() => { result.current.openPlaceEditor(noCoords) }) + expect(result.current.form).toMatchObject({ editingPlace: noCoords, editingAssignmentId: null, showPlaceForm: true }) + act(() => { result.current.openPlaceEditor({ ...pump, id: 6 }) }) + expect(result.current.form.editingPlace?.id).toBe(6) + }) + + it('FE-TP-OPENERS-015: an ordinary place opens the form on its one visit, a tour on the phone selects it instead', () => { + const { result } = renderOpeners() + + act(() => { result.current.openPlaceEditor(pump) }) + expect(result.current.stopDraft).toBeNull() + expect(result.current.form).toMatchObject({ editingPlace: pump, editingAssignmentId: 102, placeFormDayId: null, serviceStopForm: false, showPlaceForm: true }) + + act(() => { result.current.openPlaceEditor(lake, 555) }) + expect(result.current.form.editingAssignmentId).toBe(555) + + const phone = renderOpeners({ isMobile: true }) + act(() => { phone.result.current.openPlaceEditor(lake, 101) }) + act(() => { phone.result.current.openPlaceEditor(pump) }) + expect(fixed.handlePlaceClick).toHaveBeenCalledTimes(1) + expect(fixed.handlePlaceClick).toHaveBeenCalledWith(1, 101) + expect(phone.result.current.form.editingPlace).toBe(pump) + }) +}) diff --git a/client/src/pages/tripPlanner/usePlaceFormOpeners.ts b/client/src/pages/tripPlanner/usePlaceFormOpeners.ts new file mode 100644 index 000000000..c6a390e45 --- /dev/null +++ b/client/src/pages/tripPlanner/usePlaceFormOpeners.ts @@ -0,0 +1,324 @@ +import { useCallback, useMemo } from 'react' +import { normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared' +import { projectOntoRoute, sliceAtMeters, type LatLng } from '../../components/Roadtrip/corridor' +import { roadtripInsertion } from '../../components/Roadtrip/dayWindow' +import type { ServiceStopMode } from '../../components/Roadtrip/manualStop' +import { isServiceStopType } from '../../components/Roadtrip/roadtripModel' +import { stopArrival } from '../../components/Roadtrip/stopArrival' +import { isOvernightCategory } from '../../components/Roadtrip/stopKinds' +import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' +import type { usePlaceLanguage } from '../../hooks/usePlaceLanguage' +import type { TripStoreState } from '../../store/tripStore' +import type { Accommodation, Place } from '../../types' +import { getDayOrder } from '../../utils/dayOrder' +import type { PlannerBase } from './plannerTypes' +import { resolvePoolAssignmentId } from './tripPlannerModel' +import type { PlaceEdits } from './usePlaceEdits' +import type { PlannerDialogs } from './usePlannerDialogs' +import type { RoadtripDriveShaping } from './useRoadtripDriveShaping' +import type { RoadtripFeed } from './useRoadtripFeed' + +interface PlaceFormOpenersOptions + extends Pick, + Pick, + Pick, + Pick, + Pick { + placeLang: ReturnType + isMobile: boolean + days: TripStoreState['days'] + /** The places the planner lists, without the stops the day lists leave out. */ + places: Place[] + /** The day lists read as a plan, without the stops only the drive wants. */ + assignments: TripStoreState['assignments'] + tripAccommodations: Accommodation[] + /** Selects a place the way a click on it in a list does. */ + handlePlaceClick: (placeId: number | null, assignmentId?: number | null) => void +} + +/** + * Every way into the place form or the stop popup: a right click on the map, a POI + * marker, a hit on the drive, a service stop added by hand with the legs it can go on, + * the popup handing over to the full form with its duplicate check, and the place editor, + * which opens the popup instead for a stop or a night of the drive. + * + * Nothing here runs an effect, so where useTripPlanner calls it changes nothing. + */ +export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) { + const { + trip, can, isMobile, placeLang, places, days, assignments, tripAccommodations, handlePlaceClick, isTourPlace, + setPrefillCoords, setEditingPlace, setEditingAssignmentId, setPlaceFormDayId, setPlaceFormPosition, setShowPlaceForm, + stopDraft, setStopDraft, serviceStopForm, serviceStopKind, setServiceStopForm, setServiceStopKind, + roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, manualStopTargetFor, + } = options + const handleMapContextMenu = useCallback(async (e, dayId?: number | null) => { + if (!can('place_edit', trip)) return + e.originalEvent?.preventDefault() + const { lat, lng } = e.latlng + setPrefillCoords({ lat, lng }) + setEditingPlace(null) + setEditingAssignmentId(null) + setPlaceFormDayId(dayId ?? null) + setServiceStopForm(false) + setShowPlaceForm(true) + try { + const { mapsApi } = await import('../../api/client') + const data = await mapsApi.reverse(lat, lng, placeLang) + if (data.name || data.address) { + setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev) + } + } catch { /* best effort */ } + }, [placeLang, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPrefillCoords, setServiceStopForm, setShowPlaceForm]) + + // Open the Add-Place form pre-filled from an OSM "explore" POI marker: all the + // data already comes from the POI, so no reverse-geocode is needed. + const openAddPlaceFromPoi = useCallback(( + poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; category?: string | null; poi_type?: string | null }, + dayId?: number | null, + /** Index within that day. Omitted, the place is appended, which is what every caller did before. */ + position?: number | null, + /** + * What the corridor popup had worked out before the traveller asked for the full + * form. Without it, leaving the popup by "more details" quietly turned a fuel stop + * into a numbered destination that counts in every total. + */ + stop?: { stopType: RoadtripStopType | null; dwellMinutes: number } | null, + ) => { + if (!can('place_edit', trip)) return + setPrefillCoords({ + lat: poi.lat, + lng: poi.lng, + name: poi.name, + address: poi.address || '', + // Checked again on the way into the form: a plugin POI's website is the plugin's + // text, and only an address a browser opens as a page belongs in the field. + website: normalizePlaceWebsite(poi.website) ?? undefined, + phone: poi.phone || undefined, + // A plugin POI's `plugin::` rides along as it is. The server never + // takes that prefix for a Google place id, so the details column makes no Google call. + osm_id: poi.osm_id, + // What the map search filed it under, so the form can preselect a category (#2282). + category: poi.poi_type || poi.category || undefined, + stop_type: stop?.stopType ?? null, + duration_minutes: stop?.dwellMinutes, + }) + setEditingPlace(null) + setEditingAssignmentId(null) + setPlaceFormDayId(dayId ?? null) + setPlaceFormPosition(position ?? null) + setServiceStopForm(false) + setShowPlaceForm(true) + }, [trip, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPlaceFormPosition, setPrefillCoords, setServiceStopForm, setShowPlaceForm]) + + const roadtripDayId = roadtripCorridor.day?.dayId ?? null + const { insertIndexFor: roadtripInsertIndexFor } = roadtripCorridor + const roadtripDayNumber = roadtripCorridor.day?.dayNumber ?? 0 + /** + * The check-out days a night started on `dayId` can end on. + * + * Ordered by the trip's own day order rather than by array position, the same rule the + * day detail panel follows: a day list can be sorted by anything, and a hotel booked + * out on "the next day" has to mean the next day of the trip. + */ + const overnightOptions = useCallback((dayId: number) => { + const ordered = [...days].sort((a, b) => getDayOrder(a, days) - getDayOrder(b, days)) + const from = ordered.findIndex(d => d.id === dayId) + const rest = from < 0 ? ordered : ordered.slice(from) + return { + days: rest.map(d => ({ id: d.id, number: d.day_number ?? 0, date: d.date ?? null })), + // The day after, or this one when it is the last: a night on the final day of a + // trip has nowhere else to end. + defaultEndDayId: rest[1]?.id ?? rest[0]?.id ?? dayId, + } + }, [days]) + + /** + * Adding a POI straight off the map, with the day it belongs to. + * + * In road trip mode that is the day being searched: without it the place lands in the + * unplanned pool, and neither column shows that pool while road trip mode is on, so a + * just-added stop disappears without a trace. Outside road trip mode nothing changes: + * `undefined` keeps the old "let the user pick" behaviour. + * + * Memoised because both map renderers rebuild every POI marker whenever this callback's + * identity changes. + */ + const handlePoiClick = useCallback((poi: Parameters[0]) => { + if (!can('place_edit', trip)) return + // A corridor hit knows how far along the drive it sits, so it can go straight into + // the chain in driving order instead of being dragged there afterwards. + // + // Gated on the FEED, not on road trip mode: the phone never turns that mode on (it + // is a data switch the mobile sheets cannot survive, see `roadtripFeedActive` in + // useRoadtripFeed), so reading it here sent every hit found on the stage map into + // the full place form instead, losing the stop kind, the stay, and the position + // worked out just above. + const hit = roadtripFeedActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null + if (hit && roadtripDayId != null) { + const displayed = roadtripRoutes.days.find(d => d.dayId === roadtripDayId) + const insert = displayed && roadtripInsertion(displayed, roadtripInsertIndexFor(hit)) + if (!insert) return + setStopDraft({ + poi: hit, + arrivalTime: displayed ? stopArrival(displayed, roadtripInsertIndexFor(hit), hit.alongKm) : null, + ...insert, + dayNumber: roadtripDayNumber, + // Only for a hit somebody could sleep at, and it is what gives the popup its + // second mode. The check-out options are the days from this one on in travel + // order; the default is the next one, which is what a night usually means. + ...(isOvernightCategory(hit.category) ? { overnight: overnightOptions(insert.dayId) } : {}), + }) + return + } + const selected = roadtripRoutes.days.find(d => d.dayId === roadtripDayId) + const target = selected && roadtripInsertion(selected, selected.stops.length) + openAddPlaceFromPoi(poi, roadtripFeedActive ? target?.dayId ?? roadtripDayId : undefined) + }, [openAddPlaceFromPoi, roadtripFeedActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip, setStopDraft]) + + /** + * Adding a stop the corridor search never found. + * + * The search reads OpenStreetMap, and a good share of the chargers actually standing + * at a motorway junction are not in it. The way round it was to leave road trip mode, + * add the place under Days, drag it onto the right day, come back and mark it a + * charging stop. + * + * So this opens the form the rest of TREK adds places with, on nothing at all: no + * place, no coordinates, no day. The traveller finds the charger in the form's own + * typed-ahead search, which is the whole reason to use it, and where the stop belongs + * is worked out at the save, from what the save carries. + */ + const openManualRoadtripStop = useCallback((kind: RoadtripStopType | null = null) => { + if (!can('place_edit', trip)) return + setEditingPlace(null) + setEditingAssignmentId(null) + setPrefillCoords(null) + // Deliberately nowhere. A corridor hit knows its day and its position before the + // form opens; this one cannot, because nothing has been chosen yet. + setPlaceFormDayId(null) + setPlaceFormPosition(null) + // Set in the same batch as the flag below, so the kind is already there when the + // form's opening effect reads the mode out of its closure. + setServiceStopKind(kind) + setServiceStopForm(true) + setShowPlaceForm(true) + }, [can, trip, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPlaceFormPosition, setPrefillCoords, setServiceStopForm, setServiceStopKind, setShowPlaceForm]) + + /** + * What the place form needs to ask for a service stop, or null for every other use. + * + * The legs are flat across the whole drive rather than per card, because the charger + * the search missed is as likely to be on tomorrow's stretch as on today's. The names + * travel as plain strings so the form does its own labelling and this stays free of + * translated text. + */ + const serviceStopMode = useMemo(() => { + if (!serviceStopForm) return null + const panelDay = roadtripCorridor.day + return { + defaultKind: serviceStopKind, + // A day that has not routed has no order to place anything in. + days: roadtripRoutes.days + .filter(day => day.geometry.length > 1) + .map(day => { + const spine: LatLng[] = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln })) + const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0) + return { + dayId: day.dayId, + dayNumber: day.dayNumber, + stops: day.stops.map(stop => stop.name), + // Each leg's own road, cut out of the day's line where the two stops it runs + // between fall on it. The form measures the place against each of these, so + // the leg it offers first is visibly the nearest one rather than a guess the + // reader has to take on trust. + legLines: day.stops.slice(0, -1).map((_, i) => + sliceAtMeters(spine, (stopsAlong[i] ?? 0) * 1000, (stopsAlong[i + 1] ?? 0) * 1000)), + } + }), + // The end of the day the panel is looking at, which is what the dialog this + // replaced did. Offered whatever else has routed, not only when nothing has: with + // its neighbours drawn and its own line still coming, a day left out of this list + // is a day a stop meant for it cannot be put on at all. + appendDay: panelDay + ? { dayId: panelDay.dayId, dayNumber: panelDay.dayNumber, position: panelDay.stops.length } + : null, + targetFor: manualStopTargetFor, + } + }, [serviceStopForm, serviceStopKind, roadtripRoutes.days, roadtripCorridor.day, manualStopTargetFor]) + + /** Hands the draft over to the full form, keeping the day and the position it worked out. */ + const stopDraftToForm = useCallback((stop?: { stopType: RoadtripStopType | null; dwellMinutes: number }) => { + if (!stopDraft) return + const { poi, dayId, position } = stopDraft + setStopDraft(null) + if (stopDraft.editing) { + const place = places.find(place => place.id === stopDraft.editing?.placeId) + if (place) { + setEditingPlace(place) + setEditingAssignmentId(resolvePoolAssignmentId(assignments, place.id)) + setShowPlaceForm(true) + } + return + } + // Carries the kind and the dwell the popup had already worked out. Leaving them + // behind is what turned a fuel stop into a numbered destination on the way to the + // full form, silently and in every total. + openAddPlaceFromPoi(poi, dayId, position, stop ?? null) + }, [stopDraft, openAddPlaceFromPoi, places, assignments, setEditingAssignmentId, setEditingPlace, setShowPlaceForm, setStopDraft]) + + /** + * A place on this trip that came from the same OSM object. + * + * The full place form warns about duplicates; without the same check here the popup + * would be the quickest way to add one petrol station twice. + */ + const stopDraftDuplicate = useMemo(() => { + if (stopDraft?.editing || !stopDraft?.poi.osm_id) return null + return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null + }, [stopDraft, places]) + + // Open the place editor from any entry point (Places pool, inspector, map). + // Times live per day-assignment, so when no day is in context resolve the + // place's lone assignment to hydrate & persist its times; with 0 or 2+ + // assignments the time is ambiguous and the modal hides the fields (#1247). + const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => { + if (isMobile && isTourPlace(place.id)) { + handlePlaceClick(place.id, preferredAssignmentId) + return + } + if (!can('place_edit', trip)) return + if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') { + const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id) + const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId)) + if (entry) { + const dayId = Number(entry[0]) + const visit = entry[1].find(visit => visit.id === visitId)! + const stay = tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId) + const category = place.stop_type ?? (stay ? 'hotel' : '') + const routedDay = roadtripRoutes.days.find(day => day.stops.some(stop => stop.assignmentId === visitId)) + const arrivalTime = routedDay?.schedule.entries[routedDay.stops.findIndex(stop => stop.assignmentId === visitId)]?.arrival ?? null + setStopDraft({ + poi: { osm_id: place.osm_id ?? '', name: place.name, lat: place.lat, lng: place.lng, category, poi_type: category, address: place.address ?? null, website: place.website ?? null, phone: place.phone ?? null, opening_hours: null, cuisine: null, source: 'trek', offRouteKm: 0, alongKm: 0 }, + arrivalTime, dayId, dayNumber: days.find(day => day.id === dayId)?.day_number ?? 0, position: visit.order_index ?? 0, + editing: { placeId: place.id, stopType: place.stop_type ?? (stay ? 'hotel' : null), dwellMinutes: place.duration_minutes ?? 30, accommodationId: stay?.id, checkIn: stay?.check_in ?? '', checkOut: stay?.check_out ?? '' }, + ...(isOvernightCategory(category) ? { overnight: { ...overnightOptions(dayId), ...(stay ? { defaultEndDayId: stay.end_day_id } : {}) } } : {}), + }) + return + } + } + setEditingPlace(place) + setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)) + setPlaceFormDayId(null) + setServiceStopForm(false) + setShowPlaceForm(true) + }, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setServiceStopForm, setShowPlaceForm, setStopDraft]) + + return { + handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick, openManualRoadtripStop, serviceStopMode, + stopDraftToForm, stopDraftDuplicate, openPlaceEditor, + } +} + +export type PlaceFormOpeners = ReturnType diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 98be08158..ea5f1d4bb 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -10,9 +10,7 @@ import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Mountain, import { resolvePluginIcon } from '../../components/shared/PluginIcon' import { useTranslation } from '../../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, healthApi, mapsApi, placesApi } from '../../api/client' -import { getDayOrder } from '../../utils/dayOrder' import { TRANSPORT_TYPES, timedSlot } from '../../utils/dayMerge' -import { isOvernightCategory } from '../../components/Roadtrip/stopKinds' import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' import type { BookingImportPreviewItem } from '@trek/shared' import { accommodationRepo } from '../../repo/accommodationRepo' @@ -25,29 +23,23 @@ import { useAuthStore } from '../../store/authStore' import { useResizablePanels } from '../../hooks/useResizablePanels' import { useTripWebSocket } from '../../hooks/useTripWebSocket' import { useRouteCalculation } from '../../hooks/useRouteCalculation' -import { roadtripInsertion } from '../../components/Roadtrip/dayWindow' import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' import { useDayClear } from './useDayClear' -import { stopArrival } from '../../components/Roadtrip/stopArrival' -import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' -import { projectOntoRoute, sliceAtMeters, type LatLng } from '../../components/Roadtrip/corridor' import { reanchorAfterRemove, reanchorByStopOrder, isServiceStopType } from '../../components/Roadtrip/roadtripModel' -import type { ServiceStopMode } from '../../components/Roadtrip/manualStop' import { inspectorStay } from '../../components/Roadtrip/stayReading' -import { normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { useTourPlaceIds } from '../../hooks/useTourPlaceIds' import { useAddonStore } from '../../store/addonStore' import { usePlannerHistory } from '../../hooks/usePlannerHistory' import { useIsTouch } from '../../hooks/useIsTouch' import { usePluginStore } from '../../store/pluginStore' -import type { Accommodation, Assignment, TripMember, Day, Place, Reservation } from '../../types' +import type { Accommodation, Assignment, TripMember, Day, Reservation } from '../../types' import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { useTileUrl } from '../../hooks/useTileUrl' -import { placesForDays, resolvePoolAssignmentId } from './tripPlannerModel' +import { placesForDays } from './tripPlannerModel' import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs' import { isRoutableReservation } from '../../utils/reservationRoutes' import { showReservationOnMap } from '../../components/Planner/bookings/showOnMap' @@ -67,6 +59,7 @@ import { useRoadtripStopEdits } from './useRoadtripStopEdits' import { useRoadtripAlternatives } from './useRoadtripAlternatives' import { useRoadtripDriveShaping } from './useRoadtripDriveShaping' import { usePlaceEdits } from './usePlaceEdits' +import { usePlaceFormOpeners } from './usePlaceFormOpeners' import { useIsPhone } from '../../mobile/useIsPhone' /** @@ -525,129 +518,6 @@ export function useTripPlanner() { setSelectedPlaceId(null) }, []) - const handleMapContextMenu = useCallback(async (e, dayId?: number | null) => { - if (!can('place_edit', trip)) return - e.originalEvent?.preventDefault() - const { lat, lng } = e.latlng - setPrefillCoords({ lat, lng }) - setEditingPlace(null) - setEditingAssignmentId(null) - setPlaceFormDayId(dayId ?? null) - setServiceStopForm(false) - setShowPlaceForm(true) - try { - const { mapsApi } = await import('../../api/client') - const data = await mapsApi.reverse(lat, lng, placeLang) - if (data.name || data.address) { - setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev) - } - } catch { /* best effort */ } - }, [placeLang, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPrefillCoords, setServiceStopForm, setShowPlaceForm]) - - // Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the - // data already comes from the POI, so no reverse-geocode is needed. - const openAddPlaceFromPoi = useCallback(( - poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; category?: string | null; poi_type?: string | null }, - dayId?: number | null, - /** Index within that day. Omitted, the place is appended, which is what every caller did before. */ - position?: number | null, - /** - * What the corridor popup had worked out before the traveller asked for the full - * form. Without it, leaving the popup by "more details" quietly turned a fuel stop - * into a numbered destination that counts in every total. - */ - stop?: { stopType: RoadtripStopType | null; dwellMinutes: number } | null, - ) => { - if (!can('place_edit', trip)) return - setPrefillCoords({ - lat: poi.lat, - lng: poi.lng, - name: poi.name, - address: poi.address || '', - // Checked again on the way into the form: a plugin POI's website is the plugin's - // text, and only an address a browser opens as a page belongs in the field. - website: normalizePlaceWebsite(poi.website) ?? undefined, - phone: poi.phone || undefined, - // A plugin POI's `plugin::` rides along as it is. The server never - // takes that prefix for a Google place id, so the details column makes no Google call. - osm_id: poi.osm_id, - // What the map search filed it under, so the form can preselect a category (#2282). - category: poi.poi_type || poi.category || undefined, - stop_type: stop?.stopType ?? null, - duration_minutes: stop?.dwellMinutes, - }) - setEditingPlace(null) - setEditingAssignmentId(null) - setPlaceFormDayId(dayId ?? null) - setPlaceFormPosition(position ?? null) - setServiceStopForm(false) - setShowPlaceForm(true) - }, [trip, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPlaceFormPosition, setPrefillCoords, setServiceStopForm, setShowPlaceForm]) - - /** - * Adding a POI straight off the map, with the day it belongs to. - * - * In road trip mode that is the day being searched: without it the place lands in the - * unplanned pool, and neither column shows that pool while road trip mode is on, so a - * just-added stop disappears without a trace. Outside road trip mode nothing changes — - * `undefined` keeps the old "let the user pick" behaviour. - * - * Memoised because both map renderers rebuild every POI marker whenever this callback's - * identity changes. - */ - const roadtripDayId = roadtripCorridor.day?.dayId ?? null - const { insertIndexFor: roadtripInsertIndexFor } = roadtripCorridor - const roadtripDayNumber = roadtripCorridor.day?.dayNumber ?? 0 - /** - * The check-out days a night started on `dayId` can end on. - * - * Ordered by the trip's own day order rather than by array position, the same rule the - * day detail panel follows: a day list can be sorted by anything, and a hotel booked - * out on "the next day" has to mean the next day of the trip. - */ - const overnightOptions = useCallback((dayId: number) => { - const ordered = [...days].sort((a, b) => getDayOrder(a, days) - getDayOrder(b, days)) - const from = ordered.findIndex(d => d.id === dayId) - const rest = from < 0 ? ordered : ordered.slice(from) - return { - days: rest.map(d => ({ id: d.id, number: d.day_number ?? 0, date: d.date ?? null })), - // The day after, or this one when it is the last: a night on the final day of a - // trip has nowhere else to end. - defaultEndDayId: rest[1]?.id ?? rest[0]?.id ?? dayId, - } - }, [days]) - - const handlePoiClick = useCallback((poi: Parameters[0]) => { - if (!can('place_edit', trip)) return - // A corridor hit knows how far along the drive it sits, so it can go straight into - // the chain in driving order instead of being dragged there afterwards. - // - // Gated on the FEED, not on road trip mode: the phone never turns that mode on (it - // is a data switch the mobile sheets cannot survive, see `roadtripFeedActive`), so - // reading it here sent every hit found on the stage map into the full place form - // instead, losing the stop kind, the stay, and the position worked out just above. - const hit = roadtripFeedActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null - if (hit && roadtripDayId != null) { - const displayed = roadtripRoutes.days.find(d => d.dayId === roadtripDayId) - const insert = displayed && roadtripInsertion(displayed, roadtripInsertIndexFor(hit)) - if (!insert) return - setStopDraft({ - poi: hit, - arrivalTime: displayed ? stopArrival(displayed, roadtripInsertIndexFor(hit), hit.alongKm) : null, - ...insert, - dayNumber: roadtripDayNumber, - // Only for a hit somebody could sleep at, and it is what gives the popup its - // second mode. The check-out options are the days from this one on in travel - // order; the default is the next one, which is what a night usually means. - ...(isOvernightCategory(hit.category) ? { overnight: overnightOptions(insert.dayId) } : {}), - }) - return - } - const selected = roadtripRoutes.days.find(d => d.dayId === roadtripDayId) - const target = selected && roadtripInsertion(selected, selected.stops.length) - openAddPlaceFromPoi(poi, roadtripFeedActive ? target?.dayId ?? roadtripDayId : undefined) - }, [openAddPlaceFromPoi, roadtripFeedActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip, setStopDraft]) - const { stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndsDayAt, setRoadtripEndDay, saveStopDraft, saveStopDraftAsNight, confirmStayRelease, reorderRoadtripStop, moveRoadtripStopToDay, @@ -673,108 +543,6 @@ export function useTripPlanner() { trip, can, toast, t, roadtripSettings, setStopDraft, roadtripRoutes, roadtripVias, roadtripCorridor, refuel, }) - /** - * Adding a stop the corridor search never found. - * - * The search reads OpenStreetMap, and a good share of the chargers actually standing - * at a motorway junction are not in it. The way round it was to leave road trip mode, - * add the place under Days, drag it onto the right day, come back and mark it a - * charging stop. - * - * So this opens the form the rest of TREK adds places with, on nothing at all: no - * place, no coordinates, no day. The traveller finds the charger in the form's own - * typed-ahead search, which is the whole reason to use it, and where the stop belongs - * is worked out at the save, from what the save carries. - */ - const openManualRoadtripStop = useCallback((kind: RoadtripStopType | null = null) => { - if (!can('place_edit', trip)) return - setEditingPlace(null) - setEditingAssignmentId(null) - setPrefillCoords(null) - // Deliberately nowhere. A corridor hit knows its day and its position before the - // form opens; this one cannot, because nothing has been chosen yet. - setPlaceFormDayId(null) - setPlaceFormPosition(null) - // Set in the same batch as the flag below, so the kind is already there when the - // form's opening effect reads the mode out of its closure. - setServiceStopKind(kind) - setServiceStopForm(true) - setShowPlaceForm(true) - }, [can, trip, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPlaceFormPosition, setPrefillCoords, setServiceStopForm, setServiceStopKind, setShowPlaceForm]) - - /** - * What the place form needs to ask for a service stop, or null for every other use. - * - * The legs are flat across the whole drive rather than per card, because the charger - * the search missed is as likely to be on tomorrow's stretch as on today's. The names - * travel as plain strings so the form does its own labelling and this stays free of - * translated text. - */ - const serviceStopMode = useMemo(() => { - if (!serviceStopForm) return null - const panelDay = roadtripCorridor.day - return { - defaultKind: serviceStopKind, - // A day that has not routed has no order to place anything in. - days: roadtripRoutes.days - .filter(day => day.geometry.length > 1) - .map(day => { - const spine: LatLng[] = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln })) - const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0) - return { - dayId: day.dayId, - dayNumber: day.dayNumber, - stops: day.stops.map(stop => stop.name), - // Each leg's own road, cut out of the day's line where the two stops it runs - // between fall on it. The form measures the place against each of these, so - // the leg it offers first is visibly the nearest one rather than a guess the - // reader has to take on trust. - legLines: day.stops.slice(0, -1).map((_, i) => - sliceAtMeters(spine, (stopsAlong[i] ?? 0) * 1000, (stopsAlong[i + 1] ?? 0) * 1000)), - } - }), - // The end of the day the panel is looking at, which is what the dialog this - // replaced did. Offered whatever else has routed, not only when nothing has: with - // its neighbours drawn and its own line still coming, a day left out of this list - // is a day a stop meant for it cannot be put on at all. - appendDay: panelDay - ? { dayId: panelDay.dayId, dayNumber: panelDay.dayNumber, position: panelDay.stops.length } - : null, - targetFor: manualStopTargetFor, - } - }, [serviceStopForm, serviceStopKind, roadtripRoutes.days, roadtripCorridor.day, manualStopTargetFor]) - - /** Hands the draft over to the full form, keeping the day and the position it worked out. */ - const stopDraftToForm = useCallback((stop?: { stopType: RoadtripStopType | null; dwellMinutes: number }) => { - if (!stopDraft) return - const { poi, dayId, position } = stopDraft - setStopDraft(null) - if (stopDraft.editing) { - const place = places.find(place => place.id === stopDraft.editing?.placeId) - if (place) { - setEditingPlace(place) - setEditingAssignmentId(resolvePoolAssignmentId(assignments, place.id)) - setShowPlaceForm(true) - } - return - } - // Carries the kind and the dwell the popup had already worked out. Leaving them - // behind is what turned a fuel stop into a numbered destination on the way to the - // full form, silently and in every total. - openAddPlaceFromPoi(poi, dayId, position, stop ?? null) - }, [stopDraft, openAddPlaceFromPoi, places, assignments, setEditingAssignmentId, setEditingPlace, setShowPlaceForm, setStopDraft]) - - /** - * A place on this trip that came from the same OSM object. - * - * The full place form warns about duplicates; without the same check here the popup - * would be the quickest way to add one petrol station twice. - */ - const stopDraftDuplicate = useMemo(() => { - if (stopDraft?.editing || !stopDraft?.poi.osm_id) return null - return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null - }, [stopDraft, places]) - const { handleSavePlace, handleDeletePlace, handleDeleteTour, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory, isTourPlace, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, @@ -786,41 +554,15 @@ export function useTripPlanner() { editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, roadtripRoutes, roadtripVias, viaLiesBefore, }) - // Open the place editor from any entry point (Places pool, inspector, map). - // Times live per day-assignment, so when no day is in context resolve the - // place's lone assignment to hydrate & persist its times; with 0 or 2+ - // assignments the time is ambiguous and the modal hides the fields (#1247). - const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => { - if (isMobile && isTourPlace(place.id)) { - handlePlaceClick(place.id, preferredAssignmentId) - return - } - if (!can('place_edit', trip)) return - if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') { - const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id) - const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId)) - if (entry) { - const dayId = Number(entry[0]) - const visit = entry[1].find(visit => visit.id === visitId)! - const stay = tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId) - const category = place.stop_type ?? (stay ? 'hotel' : '') - const routedDay = roadtripRoutes.days.find(day => day.stops.some(stop => stop.assignmentId === visitId)) - const arrivalTime = routedDay?.schedule.entries[routedDay.stops.findIndex(stop => stop.assignmentId === visitId)]?.arrival ?? null - setStopDraft({ - poi: { osm_id: place.osm_id ?? '', name: place.name, lat: place.lat, lng: place.lng, category, poi_type: category, address: place.address ?? null, website: place.website ?? null, phone: place.phone ?? null, opening_hours: null, cuisine: null, source: 'trek', offRouteKm: 0, alongKm: 0 }, - arrivalTime, dayId, dayNumber: days.find(day => day.id === dayId)?.day_number ?? 0, position: visit.order_index ?? 0, - editing: { placeId: place.id, stopType: place.stop_type ?? (stay ? 'hotel' : null), dwellMinutes: place.duration_minutes ?? 30, accommodationId: stay?.id, checkIn: stay?.check_in ?? '', checkOut: stay?.check_out ?? '' }, - ...(isOvernightCategory(category) ? { overnight: { ...overnightOptions(dayId), ...(stay ? { defaultEndDayId: stay.end_day_id } : {}) } } : {}), - }) - return - } - } - setEditingPlace(place) - setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)) - setPlaceFormDayId(null) - setServiceStopForm(false) - setShowPlaceForm(true) - }, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setServiceStopForm, setShowPlaceForm, setStopDraft]) + const { + openPlaceEditor, stopDraftToForm, stopDraftDuplicate, openManualRoadtripStop, serviceStopMode, + handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick, + } = usePlaceFormOpeners({ + trip, can, placeLang, isMobile, days, places, assignments, tripAccommodations, handlePlaceClick, + stopDraft, setStopDraft, serviceStopForm, setServiceStopForm, serviceStopKind, setServiceStopKind, + setPrefillCoords, setEditingPlace, setEditingAssignmentId, setPlaceFormDayId, setPlaceFormPosition, setShowPlaceForm, + roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, manualStopTargetFor, isTourPlace, + }) const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => { const target = dayId || selectedDayId