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