mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
2f6f921aef
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier. The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle. On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2871 lines
154 KiB
TypeScript
2871 lines
154 KiB
TypeScript
import { useLoadRoadtripSettings, useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
|
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
|
import { publishRoadtripPreferences } from '../../store/roadtripPreferencesStore'
|
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useCanDo } from '../../store/permissionsStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { dayColor } from '../../components/Roadtrip/dayColors'
|
|
import { getCached, fetchPhoto } from '../../services/photoService'
|
|
import { useToast } from '../../components/shared/Toast'
|
|
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Route } from 'lucide-react'
|
|
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
|
|
import { useTranslation, translateApiError } from '../../i18n'
|
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, 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'
|
|
import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb'
|
|
import { isEffectivelyOffline } from '../../sync/networkMode'
|
|
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useResizablePanels } from '../../hooks/useResizablePanels'
|
|
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
|
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
|
import { useRoadtripRoutes, type RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes'
|
|
import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDayPoints'
|
|
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
|
|
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
|
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
|
import { carrierReservationIds, isStoredStop, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip'
|
|
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
|
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
|
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
|
|
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
|
import { PHONE_CORRIDOR_OPTIONS } from '../../components/Roadtrip/corridorSearchModel'
|
|
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
|
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
|
import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion'
|
|
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
|
|
import { openOn, useRouteAlternatives, type RailDrive } from '../../components/Roadtrip/useRouteAlternatives'
|
|
import { alternativesBusy, buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
|
import { pinAlternative, railDriveOn, railLegAt, refusalHint, type PinProof } from '../../components/Roadtrip/alternativePins'
|
|
import { stopArrival } from '../../components/Roadtrip/stopArrival'
|
|
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
|
|
import { projectOntoRoute, sliceAtMeters, type CorridorHit, type LatLng } from '../../components/Roadtrip/corridor'
|
|
import {
|
|
insertIndexForAlong,
|
|
reanchorAfterInsert,
|
|
reanchorAfterRemove,
|
|
reanchorByStopOrder,
|
|
isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
|
import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop'
|
|
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
|
import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal'
|
|
import { inspectorStay } from '../../components/Roadtrip/stayReading'
|
|
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
|
|
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
|
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
|
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
|
import { useIsTouch } from '../../hooks/useIsTouch'
|
|
import { usePluginStore } from '../../store/pluginStore'
|
|
import type { Accommodation, Assignment, TripMember, Day, Place, Reservation } from '../../types'
|
|
import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
|
import { useTileUrl } from '../../hooks/useTileUrl'
|
|
import { applyStayStops } from '../../store/stayStops'
|
|
import { placesForDays, resolvePoolAssignmentId } from './tripPlannerModel'
|
|
import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
|
|
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
|
import {
|
|
parseStoredConnections, resolveEffectiveConnections, resolveVisibleConnectionIds,
|
|
toggleConnectionId, toggleAllConnections as flipAllConnectionsMode,
|
|
type StoredConnections,
|
|
} from '../../utils/connectionsVisibility'
|
|
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
|
|
import { useDayDelete } from './useDayDelete'
|
|
|
|
/** Stable empty list so the road trip hook stays inert while its mode is off. */
|
|
const EMPTY_DAYS: Day[] = []
|
|
const EMPTY_RESERVATIONS: Reservation[] = []
|
|
|
|
/**
|
|
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
|
* gating, accommodations/members loading, the tab + resizable-panel + selection
|
|
* state, every place/assignment/reservation/transport CRUD handler (with undo),
|
|
* the map filters/derivations and the splash gate. TripPlannerPage stays a
|
|
* wiring container that lays out the day/map/places panes and modals.
|
|
* Behaviour is identical to the previous in-component logic.
|
|
*/
|
|
export function useTripPlanner() {
|
|
const { id } = useParams<{ id: string }>()
|
|
// The route param is a string; convert once here so every downstream component
|
|
// prop and store call gets a real number. An absent/invalid id becomes NaN,
|
|
// which stays falsy in the `if (tripId)` guards below.
|
|
const tripId = id ? Number(id) : Number.NaN
|
|
const navigate = useNavigate()
|
|
const toast = useToast()
|
|
const { t, language, locale } = useTranslation()
|
|
const { settings } = useSettingsStore()
|
|
const roadtripSettings = useRoadtripSettings(s => s, tripId)
|
|
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
|
|
const allPlugins = usePluginStore(s => s.plugins)
|
|
const pluginsLoaded = usePluginStore(s => s.loaded)
|
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
|
const trip = useTripStore(s => s.trip)
|
|
const days = useTripStore(s => s.days)
|
|
const allPlaces = useTripStore(s => s.places)
|
|
const storedAssignments = useTripStore(s => s.assignments)
|
|
const packingItems = useTripStore(s => s.packingItems)
|
|
const todoItems = useTripStore(s => s.todoItems)
|
|
const categories = useTripStore(s => s.categories)
|
|
const reservations = useTripStore(s => s.reservations)
|
|
const budgetItems = useTripStore(s => s.budgetItems)
|
|
const files = useTripStore(s => s.files)
|
|
const selectedDayId = useTripStore(s => s.selectedDayId)
|
|
const isLoading = useTripStore(s => s.isLoading)
|
|
// Actions — stable references, don't cause re-renders
|
|
const tripActions = useRef(useTripStore.getState()).current
|
|
const can = useCanDo()
|
|
const canUploadFiles = can('file_upload', trip)
|
|
const { pushUndo, undo, canUndo, lastActionLabel } = usePlannerHistory()
|
|
|
|
const handleUndo = useCallback(async () => {
|
|
const label = lastActionLabel
|
|
await undo()
|
|
toast.info(t('undo.done', { action: label ?? '' }))
|
|
}, [undo, lastActionLabel, toast])
|
|
|
|
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, dawarich: false })
|
|
// The values above are an optimistic guess until the addon feed answers. The
|
|
// tab guard below waits for this before evicting anything, so a tab we were
|
|
// asked to open ('collab' in particular, guessed off) survives the gap.
|
|
const [addonsLoaded, setAddonsLoaded] = useState<boolean>(false)
|
|
// Road trip mode swaps the plan view's left rail (and later its map layer) for the
|
|
// drive-first reading of the same trip. Per trip and per session, like the tab choice:
|
|
// someone planning a road trip stays in it across reloads without it leaking into
|
|
// their next, non-driving trip.
|
|
const [storedRoadtripMode, setRoadtripMode] = useState<boolean>(() => sessionStorage.getItem(`trip-roadtrip-${tripId}`) === '1')
|
|
// Declared here rather than with the other layout state further down, because
|
|
// road-trip mode is decided on it and the assignment and place lists below are
|
|
// decided on that. One subscriber for the whole hook.
|
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
|
useEffect(() => {
|
|
const mq = window.matchMedia('(max-width: 767px)')
|
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
|
mq.addEventListener('change', handler)
|
|
return () => mq.removeEventListener('change', handler)
|
|
}, [])
|
|
// The phone shell has no road-trip surface at all: no rail, no drive lines, and
|
|
// no switch to turn the mode back off. Narrowing a desktop window past the
|
|
// phone breakpoint used to carry the flag across anyway, which took the trip
|
|
// overview pill away with nothing in its place and listed every booked night
|
|
// twice. The flag is kept, so widening the window again returns to the drive.
|
|
const roadtripMode = storedRoadtripMode && !isMobile
|
|
// Two reasons a stop can be road-trip-only, and they are not the same reason.
|
|
//
|
|
// The switch is the traveller's: it hides the petrol stations and rest areas
|
|
// they added along the drive from a day list they want to read as a plan.
|
|
//
|
|
// A stop a lodging booking put there is hidden whatever the switch says,
|
|
// because the day already shows that booking as its own overnight block and
|
|
// the row would be the same hotel a second time. Road trip mode wants it: the
|
|
// drive has to end somewhere, and that somewhere is where you sleep.
|
|
const assignments = useMemo(() => {
|
|
if (roadtripMode) return storedAssignments
|
|
const hideServiceStops = roadtripSettings.roadtrip_service_stops_in_days === false
|
|
const hidden = (visit: Assignment) => visit.accommodation_id != null
|
|
|| (hideServiceStops && isServiceStopType(visit.place?.stop_type))
|
|
// Same object back when nothing is hidden, so a trip without bookings does not
|
|
// rebuild every day list on each render of this hook.
|
|
if (!Object.values(storedAssignments).some(visits => visits.some(hidden))) return storedAssignments
|
|
return Object.fromEntries(
|
|
Object.entries(storedAssignments).map(([dayId, visits]) => [dayId, visits.filter(v => !hidden(v))]),
|
|
)
|
|
}, [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, storedAssignments])
|
|
const toggleRoadtripMode = useCallback(() => {
|
|
setRoadtripMode(prev => {
|
|
const next = !prev
|
|
sessionStorage.setItem(`trip-roadtrip-${tripId}`, next ? '1' : '0')
|
|
return next
|
|
})
|
|
}, [tripId])
|
|
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; links: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, links: true, polls: true, whatsnext: true })
|
|
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
|
const places = useMemo(
|
|
() => (roadtripMode
|
|
? allPlaces
|
|
: placesForDays(allPlaces, roadtripSettings.roadtrip_service_stops_in_days === false, { accommodations: tripAccommodations, reservations })),
|
|
[roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, allPlaces, tripAccommodations, reservations],
|
|
)
|
|
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
|
|
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
|
|
|
// Re-fetch the trip roster so consumers (Costs participants, Collab, …) pick up a
|
|
// just-added guest or member without a full page reload.
|
|
const refreshMembers = useCallback(() => {
|
|
if (!tripId || isEffectivelyOffline()) return
|
|
tripsApi.getMembers(tripId).then(d => {
|
|
const all = [d.owner, ...(d.members || [])].filter(Boolean)
|
|
setTripMembers(all)
|
|
}).catch(() => {})
|
|
}, [tripId])
|
|
|
|
const loadAccommodations = useCallback(() => {
|
|
if (tripId) {
|
|
accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
|
tripActions.loadReservations(tripId)
|
|
}
|
|
}, [tripId])
|
|
|
|
useEffect(() => {
|
|
addonsApi.enabled().then(data => {
|
|
const map: Record<string, boolean> = {}
|
|
data.addons.forEach(a => { map[a.id] = true })
|
|
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, dawarich: !!map.dawarich })
|
|
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
|
|
}).catch(() => {}).finally(() => setAddonsLoaded(true))
|
|
authApi.getAppConfig().then(config => {
|
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
|
|
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
|
|
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
|
|
|
|
// A trip-page plugin may replace core tabs while it's active (its manifest names
|
|
// them; 'plan' is never replaceable) and may pick where its own tab sits.
|
|
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
|
|
const TRIP_TABS = [
|
|
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
|
|
{ id: 'transports', label: t(TRIP_TAB_LABEL_KEYS.transports), icon: Train },
|
|
{ id: 'buchungen', label: t(TRIP_TAB_LABEL_KEYS.buchungen), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
|
|
// Phone only: the desktop reaches the drive through the mode switch beside the
|
|
// day plan, and a second entry point there would be a tab nobody needs.
|
|
...(enabledAddons.roadtrip && isMobile ? [{ id: 'roadtrip', label: t(TRIP_TAB_LABEL_KEYS.roadtrip), icon: Route }] : []),
|
|
...(enabledAddons.packing ? [{ id: 'listen', label: t(TRIP_TAB_LABEL_KEYS.listen), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
|
|
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
|
|
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
|
|
...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []),
|
|
].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id))
|
|
// Positioned plugin tabs splice in ascending order so two positions stay stable;
|
|
// the rest append, exactly as before this capability existed.
|
|
const positioned = tripPagePlugins.filter(p => p.tripPage?.position != null).sort((a, b) => (a.tripPage!.position! - b.tripPage!.position!))
|
|
for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })
|
|
for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
|
|
// ?tab=<id> opens the trip straight on that tab (the startup destination
|
|
// setting, a browser shortcut, a wrapper app). It beats the session's last
|
|
// tab because it is an explicit request for this one, and it is read in the
|
|
// initializer rather than an effect so the planner never paints the plan view
|
|
// first and swaps a frame later.
|
|
const [activeTab, setActiveTab] = useState<string>(() => {
|
|
const requested = searchParams.get('tab')
|
|
if (requested && isDeepLinkableTripTab(requested)) return requested
|
|
return sessionStorage.getItem(`trip-tab-${tripId}`) || 'plan'
|
|
})
|
|
|
|
useEffect(() => {
|
|
// Don't evict a saved plugin tab before the plugin feed has loaded.
|
|
if (activeTab.startsWith('plugin:') && !pluginsLoaded) return
|
|
// Same for the addon-owned tabs: until the feed answers, enabledAddons is a
|
|
// guess, and evicting on a guess would drop a legitimately requested tab.
|
|
if (!addonsLoaded) return
|
|
const validTabIds = TRIP_TABS.map(t => t.id)
|
|
if (!validTabIds.includes(activeTab)) {
|
|
setActiveTab('plan')
|
|
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
|
}
|
|
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded])
|
|
|
|
const handleTabChange = (rawTabId: string): void => {
|
|
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
|
|
// (e.g. onNavigateToFiles) could still target it and render a dead panel with
|
|
// no active pill — fall back to the plan view like the invalid-tab guard does.
|
|
const tabId = replacedTabs.has(rawTabId) ? 'plan' : rawTabId
|
|
setActiveTab(tabId)
|
|
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
|
|
if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
|
|
if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
|
|
}
|
|
|
|
// handleTabChange is where a tab's lazy load and its session memory happen, and
|
|
// the tab we *start* on never goes through it — neither a ?tab= deep link nor a
|
|
// tab restored from a previous visit. Catch both up once per trip, or opening
|
|
// straight into Files shows an empty list.
|
|
const startTabSettled = useRef<number | null>(null)
|
|
useEffect(() => {
|
|
if (!tripId || startTabSettled.current === tripId) return
|
|
startTabSettled.current = tripId
|
|
sessionStorage.setItem(`trip-tab-${tripId}`, activeTab)
|
|
if (activeTab === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
|
|
if (activeTab === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
|
|
}, [tripId])
|
|
const {
|
|
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
|
leftHidden, rightHidden, toggleLeft, toggleRight, narrow: narrowPanels,
|
|
startResizeLeft, startResizeRight,
|
|
} = useResizablePanels()
|
|
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
|
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
|
|
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
|
|
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
|
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
|
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null>(null)
|
|
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
|
// Day context of the open form. Set only by the day-scoped entry points (the
|
|
// mobile day toolbar, a long-press on the mobile map); every other opener
|
|
// clears it, so a place added from the pool still lands in the pool (#1998).
|
|
const [placeFormDayId, setPlaceFormDayId] = useState<number | null>(null)
|
|
/**
|
|
* Where in the day the place being added belongs, when the caller knows.
|
|
* Null means the old behaviour: the server appends it at the end.
|
|
*/
|
|
const [placeFormPosition, setPlaceFormPosition] = useState<number | null>(null)
|
|
// The position belongs to the form it was opened with and to nothing after it. The
|
|
// day-scoped openers set the day, the form's close clears the coordinates, but the
|
|
// position is written by one opener and read by every save, so a stop handed to the
|
|
// form from the corridor popup once left the next add from any day landing at that
|
|
// same index. Tied to the form being open, the only time it means anything.
|
|
useEffect(() => {
|
|
if (!showPlaceForm) setPlaceFormPosition(null)
|
|
}, [showPlaceForm])
|
|
/**
|
|
* Whether the open place form is asking for a service stop on the drive.
|
|
*
|
|
* Only the road trip's "add manually" sets it, and everything that opens the form for
|
|
* anything else clears it, so the ordinary add, the edit and the corridor hit are the
|
|
* form they have always been.
|
|
*/
|
|
const [serviceStopForm, setServiceStopForm] = useState(false)
|
|
/**
|
|
* The kind that form opens on, taken from what the corridor panel was looking for.
|
|
*
|
|
* Beside the flag rather than inside it, because it is written by the same click and
|
|
* read by the same memo, and a second piece of state is cheaper to follow than a flag
|
|
* that is sometimes a boolean and sometimes an object.
|
|
*/
|
|
const [serviceStopKind, setServiceStopKind] = useState<RoadtripStopType | null>(null)
|
|
/**
|
|
* The corridor hit waiting to become a stop, while the small popup is open.
|
|
*
|
|
* The full place form is the wrong question for a petrol station — category, price,
|
|
* photo, notes and files are all empty for one — so in road trip mode a hit opens this
|
|
* instead, and the form stays one click away behind "more details".
|
|
*/
|
|
const [stopDraft, setStopDraft] = useState<RoadtripStopDraft | null>(null)
|
|
/**
|
|
* A booked night the popup was asked to turn into a pause, waiting for a yes.
|
|
*
|
|
* The switch in the popup reads like a change of stop kind, but the night is a
|
|
* booking row, and the server takes the reservation and the expense written against
|
|
* it down with that row. It is the one write the popup can make that nothing brings
|
|
* back, so it is the one that asks first. The name and the booking title are read
|
|
* once, here, so the dialog does not have to know where either lives.
|
|
*/
|
|
const [stayRelease, setStayRelease] = useState<{
|
|
stop: { stopType: RoadtripStopType | null; dwellMinutes: number }
|
|
name: string
|
|
booking: string | null
|
|
} | null>(null)
|
|
const [reservationModalDayId, setReservationModalDayId] = useState<number | null>(null)
|
|
|
|
// The bottom-nav "+" opens the new-place form via ?create=place.
|
|
useEffect(() => {
|
|
if (searchParams.get('create') === 'place') {
|
|
setEditingPlace(null); setEditingAssignmentId(null); setPlaceFormDayId(null); setShowPlaceForm(true)
|
|
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
|
}
|
|
}, [searchParams])
|
|
|
|
// ?tab= has done its job in the state initializer above — drop it so the URL
|
|
// stops claiming a tab the user may have since switched away from. The session
|
|
// memory keeps the choice across a reload.
|
|
useEffect(() => {
|
|
if (searchParams.get('tab') === null) return
|
|
setSearchParams(p => { p.delete('tab'); return p }, { replace: true })
|
|
}, [searchParams])
|
|
const [showTripForm, setShowTripForm] = useState<boolean>(false)
|
|
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
|
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
|
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
|
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
|
// Which tab opened the importer. Only ever a tie-breaker — see openImportItem.
|
|
const [bookingImportKind, setBookingImportKind] = useState<'transports' | 'bookings'>('bookings')
|
|
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
|
const { available: airTrailAvailable } = useAirtrailConnection()
|
|
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(false)
|
|
// Pull this user's AirTrail edits as soon as they open the trip, so changes
|
|
// made in AirTrail show up without waiting for the background poll.
|
|
const airtrailSyncedRef = useRef<number | null>(null)
|
|
useEffect(() => {
|
|
if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return
|
|
airtrailSyncedRef.current = tripId
|
|
airtrailApi.sync()
|
|
.then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) })
|
|
.catch(() => {})
|
|
}, [airTrailAvailable, tripId, tripActions])
|
|
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
|
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
|
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
|
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
|
// Public transit (#1065): open the TransportModal in its Automated mode, seed
|
|
// the search (change-route), and show the journey view for a saved entry.
|
|
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
|
|
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null; time?: string | null } | null>(null)
|
|
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
|
|
|
|
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs
|
|
// it opens the booking / transport modal via ?create=reservation|transport
|
|
// (place is handled above, expense in CostsPanel). #1349
|
|
useEffect(() => {
|
|
const intent = searchParams.get('create')
|
|
if (intent === 'reservation') {
|
|
setEditingReservation(null); setBookingForAssignmentId(null); setShowReservationModal(true)
|
|
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
|
} else if (intent === 'transport') {
|
|
setEditingTransport(null); setTransportModalDayId(null); setShowTransportModal(true)
|
|
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
|
}
|
|
}, [searchParams])
|
|
// Review-before-save import: each parsed item pre-fills the normal edit modal so
|
|
// the user checks/fixes it, then saves. A ref drives the queue (no stale closures).
|
|
const [reservationPrefill, setReservationPrefill] = useState<BookingReviewDraft | null>(null)
|
|
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
|
|
const [importReviewActive, setImportReviewActive] = useState(false)
|
|
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
|
|
// The files this import was parsed from, so each reviewed booking can attach its source doc.
|
|
const importSourceFilesRef = useRef<File[]>([])
|
|
// The tab the items under review came from. A ref, not the bookingImportKind
|
|
// state: the parse outlives navigation and reload, and the review is triggered
|
|
// by the global widget, so by then the state has remounted back to its default.
|
|
// The value comes off the persisted job (#2076).
|
|
const importKindRef = useRef<'transports' | 'bookings'>('bookings')
|
|
// Manual route planning: off by default, toggled from the day-plan footer. Mode
|
|
// is per-session and selects which travel time the connectors show — either a
|
|
// built-in OSRM profile or a plugin route profile ('plugin:<id>/<profile>').
|
|
// Per-trip route visibility. `null` = the user has never said anything, which
|
|
// is what lets the mobile map switch it on by default; an explicit false has to
|
|
// survive every later map entry, and it used to be clobbered on each one (#2003).
|
|
const routeStorageKey = tripId ? `trek:day-route:${tripId}` : null
|
|
const [routeChoice, setRouteChoice] = useState<boolean | null>(() => {
|
|
if (typeof window === 'undefined' || !routeStorageKey) return null
|
|
const raw = window.localStorage.getItem(routeStorageKey)
|
|
return raw === 'true' ? true : raw === 'false' ? false : null
|
|
})
|
|
const routeShown = routeChoice === true
|
|
const setRouteShown = useCallback((v: boolean | ((prev: boolean) => boolean)) => {
|
|
setRouteChoice(prev => {
|
|
const next = typeof v === 'function' ? v(prev === true) : v
|
|
if (routeStorageKey && typeof window !== 'undefined') {
|
|
window.localStorage.setItem(routeStorageKey, String(next))
|
|
}
|
|
return next
|
|
})
|
|
}, [routeStorageKey])
|
|
// The mobile map opens with the day's route drawn — a default, not a choice, so
|
|
// it never overwrites an explicit off and is never written to storage itself.
|
|
const autoShowRoute = useCallback(() => {
|
|
setRouteChoice(prev => (prev === null ? true : prev))
|
|
}, [])
|
|
// What the planner maps actually draw. The persisted toggle can rehydrate as
|
|
// true while no day is selected yet (trip re-entry resets the selection, and
|
|
// a second click on the day header clears it) — without a day context the
|
|
// per-day transit filter is off, so the map would draw every automated
|
|
// transport in the trip (#2019).
|
|
const transitRoutesShown = routeShown && selectedDayId != null
|
|
const [routeProfile, setRouteProfile] = useState<string>('driving')
|
|
// Whole-trip route overview (#1736): every day's route at once, each in its own
|
|
// colour. Per trip and per session like road trip mode — it answers "what does the
|
|
// whole thing look like", which is a question you ask of one trip, not a preference.
|
|
const [overviewShown, setOverviewShown] = useState<boolean>(() => sessionStorage.getItem(`trip-overview-${tripId}`) === '1')
|
|
const toggleOverview = useCallback(() => {
|
|
setOverviewShown(prev => {
|
|
const next = !prev
|
|
sessionStorage.setItem(`trip-overview-${tripId}`, next ? '1' : '0')
|
|
return next
|
|
})
|
|
}, [tripId])
|
|
// The recorded route from Dawarich (#2279), per trip and per session for the
|
|
// same reason as the overview above: it answers "what actually happened on
|
|
// this trip", which is a question about one trip rather than a preference.
|
|
const [dawarichTrailShown, setDawarichTrailShown] = useState<boolean>(
|
|
() => sessionStorage.getItem(`trip-dawarich-${tripId}`) === '1',
|
|
)
|
|
const toggleDawarichTrail = useCallback(() => {
|
|
setDawarichTrailShown(prev => {
|
|
const next = !prev
|
|
sessionStorage.setItem(`trip-dawarich-${tripId}`, next ? '1' : '0')
|
|
return next
|
|
})
|
|
}, [tripId])
|
|
// Fetched here rather than in MapViewAuto so the desktop and the phone share
|
|
// one request, and so the pill that toggles it can show why there is no line.
|
|
const dawarichTrail = useDawarichTrail(tripId, dawarichTrailShown)
|
|
|
|
const [fitKey, setFitKey] = useState<number>(0)
|
|
const initialFitTripId = useRef<number | null>(null)
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
|
const mobilePlanScrollTopRef = useRef<number>(0)
|
|
const mobilePlacesScrollTopRef = useRef<number>(0)
|
|
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
|
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
|
/**
|
|
* The sentence the delete question adds when a night is booked at one of the places.
|
|
*
|
|
* The server takes a booked night down with its place, and with the night the
|
|
* booking made for it and the expense written against that booking. The question
|
|
* itself only names the place, and those are the rows the traveller least expects
|
|
* to lose, so the dialog says so before the yes. The expense list is loaded with
|
|
* the costs tab, not here, so the sentence speaks of any expense rather than
|
|
* counting them. A booking named after its hotel, which is how most are named,
|
|
* is not quoted a second time. Null when nothing beyond the place is at stake.
|
|
*/
|
|
const bookedNightsNote = useCallback((placeIds: number[]): string | null => {
|
|
const stays = tripAccommodations.filter(stay => stay.place_id != null && placeIds.includes(stay.place_id))
|
|
if (stays.length === 0) return null
|
|
const names = [...new Set(stays.map(stay => allPlaces.find(p => p.id === stay.place_id)?.name ?? stay.place_name ?? ''))]
|
|
.filter(Boolean)
|
|
const bookings = stays
|
|
.map(stay => reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === stay.id)?.title
|
|
?? stay.reservation_title ?? null)
|
|
.filter((title): title is string => !!title)
|
|
const name = names.join(', ')
|
|
if (bookings.length === 0) return t('trip.confirm.deletePlaceNight', { name })
|
|
return bookings.every(title => names.includes(title))
|
|
? t('trip.confirm.deletePlaceBookedSame', { name })
|
|
: t('trip.confirm.deletePlaceBooked', { name, booking: bookings.join(', ') })
|
|
}, [tripAccommodations, allPlaces, reservations, t])
|
|
const deletePlaceNote = useMemo(
|
|
() => (deletePlaceId ? bookedNightsNote([deletePlaceId]) : null),
|
|
[deletePlaceId, bookedNightsNote],
|
|
)
|
|
const deletePlacesNote = useMemo(
|
|
() => (deletePlaceIds?.length ? bookedNightsNote(deletePlaceIds) : null),
|
|
[deletePlaceIds, bookedNightsNote],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!trip) return
|
|
if (initialFitTripId.current === trip.id) return
|
|
const hasGeoPlaces = places.some(p => p.lat != null && p.lng != null)
|
|
if (!hasGeoPlaces) return
|
|
initialFitTripId.current = trip.id
|
|
setFitKey(k => k + 1)
|
|
}, [trip, places])
|
|
|
|
useEffect(() => {
|
|
// The server runs the import when EITHER kitinerary or the LLM parser is
|
|
// there (booking-import.service.ts), so gating the entry point on kitinerary
|
|
// alone hid a working feature on LLM-only instances (#2007).
|
|
healthApi.features().then(f => setBookingImportAvailable(f.bookingImport || f.aiParsing)).catch(() => {})
|
|
}, [])
|
|
|
|
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
|
|
// Per-trip route-visibility preference — null means "never touched", which
|
|
// falls back to the account-wide map_always_show_routes default (see
|
|
// connectionsVisibility.ts). That fallback is purely computed, never
|
|
// written, so flipping the account setting later doesn't silently override
|
|
// a trip you've already made an explicit choice on.
|
|
const [storedConnections, setStoredConnections] = useState<StoredConnections | null>(() => {
|
|
if (typeof window === 'undefined' || !connectionsStorageKey) return null
|
|
return parseStoredConnections(window.localStorage.getItem(connectionsStorageKey))
|
|
})
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined' || !connectionsStorageKey || !storedConnections) return
|
|
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(storedConnections))
|
|
}, [connectionsStorageKey, storedConnections])
|
|
const alwaysShowRoutesDefault = settings.map_always_show_routes === true
|
|
const routableReservationIds = useMemo(
|
|
() => reservations.filter(isRoutableReservation).map(r => r.id),
|
|
[reservations]
|
|
)
|
|
const effectiveConnections = useMemo(
|
|
() => resolveEffectiveConnections(storedConnections, alwaysShowRoutesDefault),
|
|
[storedConnections, alwaysShowRoutesDefault]
|
|
)
|
|
const visibleConnections = useMemo(
|
|
() => resolveVisibleConnectionIds(effectiveConnections, routableReservationIds),
|
|
[effectiveConnections, routableReservationIds]
|
|
)
|
|
const allConnectionsShown = effectiveConnections.mode === 'all-except'
|
|
const toggleConnection = useCallback((id: number) => {
|
|
setStoredConnections(prev => toggleConnectionId(prev, alwaysShowRoutesDefault, id))
|
|
}, [alwaysShowRoutesDefault])
|
|
const toggleAllConnections = useCallback(() => {
|
|
setStoredConnections(prev => flipAllConnectionsMode(prev, alwaysShowRoutesDefault))
|
|
}, [alwaysShowRoutesDefault])
|
|
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
|
|
|
|
// Layout is width-driven (isMobile); the drag bridge is pointer-driven (isTouch).
|
|
// Conflating them is what left a tablet's places list undraggable-but-unscrollable (#1432).
|
|
const isTouch = useIsTouch()
|
|
|
|
// Start photo fetches during splash screen so images are ready when map mounts
|
|
useEffect(() => {
|
|
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
|
|
for (const p of places) {
|
|
if (p.image_url) continue
|
|
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
|
|
if (!cacheKey || getCached(cacheKey)) continue
|
|
const photoId = p.google_place_id || p.osm_id
|
|
if (photoId || (p.lat && p.lng)) {
|
|
fetchPhoto(cacheKey, photoId || `coords:${p.lat}:${p.lng}`, p.lat, p.lng, p.name)
|
|
}
|
|
}
|
|
}, [isLoading, places])
|
|
|
|
// Load the trip. loadTrip hydrates every trip-scoped slice (days, places,
|
|
// packing, todo, budget, reservations, files) so offline hydration is uniform
|
|
// and there's no cross-trip bleed; members/accommodations load alongside.
|
|
useEffect(() => {
|
|
if (tripId) {
|
|
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
|
loadAccommodations()
|
|
if (isEffectivelyOffline()) {
|
|
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
|
.then(rows => setTripMembers(rows))
|
|
.catch(() => {})
|
|
} else {
|
|
refreshMembers()
|
|
}
|
|
}
|
|
}, [tripId])
|
|
|
|
// Accommodations live in this hook's local state, so store-level refreshes
|
|
// (remote trip date change, reconnect hydration) nudge us via this event (#1288).
|
|
useEffect(() => {
|
|
const onRefresh = () => loadAccommodations()
|
|
window.addEventListener('accommodations:refresh', onRefresh)
|
|
return () => window.removeEventListener('accommodations:refresh', onRefresh)
|
|
}, [loadAccommodations])
|
|
|
|
useTripWebSocket(tripId)
|
|
|
|
// Same filter the places sidebar renders — shared via the store so tab
|
|
// switches can't desync the marker set from the filter UI (#1541).
|
|
const placesFilter = useTripStore((s) => s.placesFilter)
|
|
const placesCategoryFilter = useTripStore((s) => s.placesCategoryFilter)
|
|
|
|
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
|
|
|
|
const mapPlaces = useMemo(() => {
|
|
// Build set of place IDs assigned to collapsed days
|
|
const hiddenPlaceIds = new Set<number>()
|
|
if (expandedDayIds) {
|
|
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
|
if (!expandedDayIds.has(Number(dayId))) {
|
|
for (const a of dayAssignments) {
|
|
if (a.place?.id) hiddenPlaceIds.add(a.place.id)
|
|
}
|
|
}
|
|
}
|
|
// Don't hide places that are also assigned to an expanded day
|
|
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
|
if (expandedDayIds.has(Number(dayId))) {
|
|
for (const a of dayAssignments) {
|
|
if (a.place?.id) hiddenPlaceIds.delete(a.place.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Planned place IDs — needed by both the 'unplanned' filter (exclude them) and
|
|
// the new 'planned' filter (keep only them). With a day selected, 'planned'
|
|
// follows it like the other filters do; with no day selected it keeps showing
|
|
// the whole plan (#2024). 'unplanned' always uses the whole-trip set — a place
|
|
// assigned to any day is not unplanned.
|
|
const plannedIds = placesFilter === 'unplanned' || placesFilter === 'planned'
|
|
? (placesFilter === 'planned' && selectedDayId
|
|
? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations: tripAccommodations, reservations })
|
|
: plannedPlaceIds({ assignments, accommodations: tripAccommodations, reservations }))
|
|
: null
|
|
|
|
return places.filter(p => {
|
|
if (!p.lat || !p.lng) return false
|
|
if (placesFilter === 'tracks' && !p.route_geometry) return false
|
|
if (placesCategoryFilter.size > 0) {
|
|
if (p.category_id == null) {
|
|
if (!placesCategoryFilter.has('uncategorized')) return false
|
|
} else if (!placesCategoryFilter.has(String(p.category_id))) return false
|
|
}
|
|
// Collapsed-day declutter hides a day's stops on every filter EXCEPT 'planned':
|
|
// there the user asked to see the whole plan on the map, so a collapsed day
|
|
// must not drop its planned places.
|
|
if (placesFilter !== 'planned' && hiddenPlaceIds.has(p.id)) return false
|
|
if (placesFilter === 'unplanned' && plannedIds && plannedIds.has(p.id)) return false
|
|
if (placesFilter === 'planned' && plannedIds && !plannedIds.has(p.id)) return false
|
|
return true
|
|
})
|
|
}, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations])
|
|
|
|
const { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
|
// Road trip mode already draws the whole trip its own way, so the overview stands
|
|
// down there rather than drawing a second set of lines over it.
|
|
const overviewActive = overviewShown && !roadtripMode
|
|
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive)
|
|
|
|
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
|
|
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
|
|
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
|
|
// The phone's own way into the drive, and the reason line 148 above stays as it is.
|
|
//
|
|
// `roadtripMode` is not a view switch, it is a data switch: `assignments` and
|
|
// `places` are derived from it for the whole hook, and every permanently mounted
|
|
// sheet of the phone shell reads those same lists. Letting the phone flip it would
|
|
// bring back exactly the regressions the comment up there describes, on paths that
|
|
// never touch a tab. The second tap on a day chip opens the day sheet, the More
|
|
// button opens the PDF export.
|
|
//
|
|
// So the phone feeds the routing round instead, and nothing else. Once the tab has
|
|
// been opened the feed stays on for as long as the trip is: a tab switch must not
|
|
// throw away legs that cost a rate-limited request each.
|
|
const roadtripTabSeen = useRef(false)
|
|
if (activeTab === 'roadtrip') roadtripTabSeen.current = true
|
|
const roadtripFeedActive = !!enabledAddons.roadtrip
|
|
&& (roadtripMode || (isMobile && (activeTab === 'roadtrip' || roadtripTabSeen.current)))
|
|
const roadtripPreferencesState = useLoadRoadtripSettings(tripId, !!enabledAddons.roadtrip)
|
|
useEffect(() => {
|
|
if (roadtripPreferencesState.failed) toast.error(t('common.error'))
|
|
}, [roadtripPreferencesState.failed, toast, t])
|
|
const dailyTimesActive = !!dayWindow(roadtripSettings.roadtrip_day_start, roadtripSettings.roadtrip_day_end)
|
|
// Fed from the same flag as the routing round: without the boundaries the phone
|
|
// would draw a drive that never ends for the night.
|
|
const dayBoundaries = useDayBoundaries(tripId, roadtripFeedActive && dailyTimesActive, assignments)
|
|
useEffect(() => { if (dayBoundaries.stale) toast.error(t('trip.toast.loadError')) }, [dayBoundaries.stale, toast, t])
|
|
const resetDayBoundaries = dayBoundaries.editable && dayBoundaries.boundaries.length && can('day_edit', trip) ? async () => {
|
|
try {
|
|
for (const boundary of dayBoundaries.boundaries) await dayBoundaries.save(boundary.day_number, null)
|
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
} : undefined
|
|
// Fed whenever the addon is on, not only while the mode is being looked at.
|
|
// The three handlers that re-anchor a day's vias — assign, remove, reorder —
|
|
// are reachable from the place inspector in both modes, and with an empty list
|
|
// they computed an empty correction and wrote nothing while the server kept
|
|
// every via pointing at a position that had moved. Deleting a stop with the
|
|
// switch off then left the detour on the wrong leg, for the traveller and for
|
|
// a collaborator who never turned the mode on at all.
|
|
const roadtripVias = useRoadtripVias(tripId, !!enabledAddons.roadtrip)
|
|
const refuel = useRefuelSearch()
|
|
const roadtripRoutes = useRoadtripRoutes(
|
|
tripId,
|
|
roadtripFeedActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS,
|
|
// Deliberately the stored list, not the filtered one: the drive wants the
|
|
// service stops and the night a lodging booking put on the map, and the filter
|
|
// above only exists to keep those out of a day list read as a plan.
|
|
roadtripFeedActive ? storedAssignments : assignments,
|
|
routeProfile,
|
|
roadtripVias.byDay,
|
|
dayBoundaries.boundaries,
|
|
tripAccommodations,
|
|
roadtripFeedActive ? reservations : EMPTY_RESERVATIONS,
|
|
)
|
|
/**
|
|
* The bookings the drive is seamed by, drawn as their own arcs beside the roads: a
|
|
* flight's route on the road trip map is the booking's line, the same one the day plan
|
|
* draws, so the ride shows where the road stops (#2428). On top of whatever the reader
|
|
* switched on by hand under Days.
|
|
*/
|
|
const roadtripConnections = useMemo(() => {
|
|
const rides = carrierReservationIds(roadtripRoutes.days)
|
|
return rides.length ? [...new Set([...visibleConnections, ...rides])] : visibleConnections
|
|
}, [roadtripRoutes.days, visibleConnections])
|
|
// Lives here rather than in the panel because the map draws what it finds.
|
|
// The trip comes with it for the vehicle: an electric car looks for chargers rather
|
|
// than for pumps, and that preference is stored per trip.
|
|
// The phone searches under a smaller ceiling: fewer boxes, a capped retry pass and a
|
|
// deadline, because a search that keeps the radio warm costs battery somebody is
|
|
// navigating on. Which stretch of the day it asks about is decided per search.
|
|
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes, tripId, isMobile ? PHONE_CORRIDOR_OPTIONS : undefined)
|
|
// Applying a track is a long job — a routing round trip per refinement — so it lives
|
|
// above the dialog: a component that unmounted halfway would leave the day holding
|
|
// half a chain of vias.
|
|
const followTrack = useFollowTrack(tripId, places, roadtripRoutes, roadtripVias)
|
|
/** How many vias each day carries, for the rail's badge. */
|
|
const roadtripViaCounts = useMemo(() => {
|
|
const counts: Record<number, number> = {}
|
|
for (const [dayId, list] of Object.entries(roadtripVias.byDay)) counts[Number(dayId)] = list.length
|
|
return counts
|
|
}, [roadtripVias.byDay])
|
|
|
|
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
|
tripActions.setSelectedDay(dayId)
|
|
if (!skipFit) setFitKey(k => k + 1)
|
|
setMobileSidebarOpen(null)
|
|
updateRouteForDay(dayId)
|
|
}, [updateRouteForDay])
|
|
|
|
const handlePlaceClick = useCallback((placeId: number | null, assignmentId?: number | null) => {
|
|
if (assignmentId) {
|
|
selectAssignment(assignmentId, placeId)
|
|
} else {
|
|
setSelectedPlaceId(placeId)
|
|
}
|
|
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
|
|
}, [selectAssignment, setSelectedPlaceId])
|
|
|
|
const handleMarkerClick = useCallback((placeId?: number) => {
|
|
if (placeId === undefined) {
|
|
setSelectedPlaceId(null)
|
|
return
|
|
}
|
|
// Find every assignment for this place (same place can sit on several
|
|
// days / be planned twice in one day). Cycle through them on repeated
|
|
// marker clicks so the sidebar highlight jumps to the next occurrence
|
|
// instead of leaving the user confused.
|
|
const allAssignments = Object.values(useTripStore.getState().assignments || {}).flat()
|
|
const matching = allAssignments.filter(a => a?.place?.id === placeId)
|
|
|
|
if (matching.length === 0) {
|
|
setSelectedPlaceId(selectedPlaceId === placeId ? null : placeId)
|
|
} else if (matching.length === 1) {
|
|
const only = matching[0]
|
|
if (selectedAssignmentId === only.id) {
|
|
setSelectedPlaceId(null)
|
|
} else {
|
|
selectAssignment(only.id, placeId)
|
|
}
|
|
} else {
|
|
const currentIdx = matching.findIndex(a => a.id === selectedAssignmentId)
|
|
const nextIdx = currentIdx === -1 ? 0 : currentIdx + 1
|
|
if (nextIdx >= matching.length) {
|
|
// cycled past the last occurrence — clear selection so the next
|
|
// click starts fresh at occurrence 0.
|
|
setSelectedPlaceId(null)
|
|
} else {
|
|
selectAssignment(matching[nextIdx].id, placeId)
|
|
}
|
|
}
|
|
setLeftCollapsed(false); setRightCollapsed(false)
|
|
}, [selectAssignment, selectedAssignmentId, selectedPlaceId, setSelectedPlaceId])
|
|
|
|
const handleMapClick = useCallback(() => {
|
|
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, language)
|
|
if (data.name || data.address) {
|
|
setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev)
|
|
}
|
|
} catch { /* best effort */ }
|
|
}, [language])
|
|
|
|
// 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 },
|
|
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 || '',
|
|
website: poi.website || undefined,
|
|
phone: poi.phone || undefined,
|
|
osm_id: poi.osm_id,
|
|
stop_type: stop?.stopType ?? null,
|
|
duration_minutes: stop?.dwellMinutes,
|
|
})
|
|
setEditingPlace(null)
|
|
setEditingAssignmentId(null)
|
|
setPlaceFormDayId(dayId ?? null)
|
|
setPlaceFormPosition(position ?? null)
|
|
setServiceStopForm(false)
|
|
setShowPlaceForm(true)
|
|
}, [trip])
|
|
|
|
/**
|
|
* 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<typeof openAddPlaceFromPoi>[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])
|
|
|
|
/**
|
|
* The stops of a day as the road trip counts them, in the order it drives them.
|
|
*
|
|
* This is the index space `after_order_index` lives in: sorted by `order_index`, and
|
|
* filtered to the rows that have coordinates, because a place the map cannot put
|
|
* anywhere is not a point the router is given. Built from the STORED list, the same
|
|
* one the routing round and the server count over. The day list read as a plan
|
|
* hides the stop a lodging booking put on the day and, behind the switch, the
|
|
* service stops; the handlers that correct a day's vias run in that mode too, and a
|
|
* plan measured on the shorter list deleted a via behind a hidden hotel or pinned
|
|
* it to a leg nobody drew. Built from the list rather than from `roadtripRoutes` so
|
|
* it also answers for a day with one stop or none, which is exactly the day a stop
|
|
* gets pushed onto when a leg turns out too long.
|
|
*/
|
|
const roadtripStopsOf = useCallback((dayId: number) =>
|
|
(storedAssignments[String(dayId)] ?? [])
|
|
.slice()
|
|
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number'),
|
|
[storedAssignments])
|
|
|
|
/**
|
|
* Where a place dropped at `position` in a day's row list lands among the stops the
|
|
* road trip counts.
|
|
*
|
|
* The day plan hands over a row index and the store splices the new row in at that
|
|
* index, so the stop's place in the chain is the number of routable rows ahead of
|
|
* it, not the index itself: a row without coordinates is never a stop.
|
|
*/
|
|
const roadtripIndexOf = useCallback((dayId: number, position: number) =>
|
|
(storedAssignments[String(dayId)] ?? [])
|
|
.slice()
|
|
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
.slice(0, Math.max(0, position))
|
|
.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
|
|
.length,
|
|
[storedAssignments])
|
|
|
|
/**
|
|
* Which half of a split leg a via belongs to, measured on the road actually driven.
|
|
*
|
|
* Both the via and the new stop are projected onto the day's current routed line, so
|
|
* the comparison is "which one does the car reach first" rather than a straight-line
|
|
* guess. The line already includes the detour the via causes, so the via sits exactly
|
|
* on it and its position along the drive is exact.
|
|
*
|
|
* Falls back to keeping the via on the first half. A projection only fails when the
|
|
* point is nowhere near the drive, and in that case leaving the anchor where it was is
|
|
* the answer that changes least.
|
|
*/
|
|
const viaLiesBefore = useCallback((dayId: number, at: { lat: number; lng: number }) => {
|
|
const line: LatLng[] = (roadtripRoutes.days.find(d => d.dayId === dayId)?.geometry ?? [])
|
|
.map(([lat, lng]) => ({ lat, lng }))
|
|
const insertAt = line.length ? projectOntoRoute(at, line)?.alongKm ?? null : null
|
|
return (via: { lat: number; lng: number }) => {
|
|
if (insertAt === null) return true
|
|
const viaAt = projectOntoRoute({ lat: via.lat, lng: via.lng }, line)?.alongKm
|
|
return viaAt === undefined || viaAt === null ? true : viaAt < insertAt
|
|
}
|
|
}, [roadtripRoutes.days])
|
|
|
|
/**
|
|
* How a day's vias move when a place at `at` lands on it at row `position`, or at its
|
|
* end without one. Null when none do: appending moves nothing, and a place without
|
|
* coordinates is never a stop.
|
|
*
|
|
* Worked out before the stop lands, the same way the road-trip popup does it: once the
|
|
* list has shifted there is no record of which leg each via was drawn for. The
|
|
* predicate decides which side of the new stop a via falls on when it is dropped into
|
|
* the middle of a leg.
|
|
*/
|
|
const viasAfterInsert = useCallback((dayId: number, position: number | undefined, at: { lat?: number | null; lng?: number | null } | undefined) => {
|
|
const stopsBefore = roadtripStopsOf(dayId)
|
|
// The position is a row index in the day list, the anchors count stops.
|
|
const insertAt = position === undefined ? stopsBefore.length : roadtripIndexOf(dayId, position)
|
|
if (insertAt >= stopsBefore.length || typeof at?.lat !== 'number' || typeof at?.lng !== 'number') return null
|
|
return reanchorAfterInsert(roadtripVias.byDay[dayId] ?? [], insertAt, viaLiesBefore(dayId, { lat: at.lat, lng: at.lng }))
|
|
}, [roadtripStopsOf, roadtripIndexOf, roadtripVias.byDay, viaLiesBefore])
|
|
|
|
/**
|
|
* Saves a corridor hit as a stop: the place itself, then its position in the day.
|
|
*
|
|
* `stop_type` is what makes it a fuel stop rather than a place that happens to sell
|
|
* fuel — the road-trip kinds are their own dimension, deliberately not one of the
|
|
* traveller's editable categories, so the palette and the meaning stay put.
|
|
*/
|
|
/**
|
|
* Stores one of the three driving limits.
|
|
*
|
|
* Straight to the settings store rather than through the offline queue: these are
|
|
* per-user preferences on the settings table, the same path the map provider and the
|
|
* distance unit take, and they are read locally the moment they change.
|
|
*/
|
|
/**
|
|
* A colour per drawn line, or none at all.
|
|
*
|
|
* Off is not "all the same colour" but an absent array: the map then paints the blue it
|
|
* has always painted, so a trip that never turns this on renders byte for byte the way
|
|
* it did.
|
|
*/
|
|
/**
|
|
* Days folded down to their header in the road trip rail.
|
|
*
|
|
* Its own state rather than the day plan's `expandedDayIds`: that one is owned by
|
|
* `DayPlanSidebar`, which republishes it from its own state every time it mounts — so a
|
|
* day folded in the rail would spring back open the moment the other view was visited.
|
|
*/
|
|
const [collapsedRoadtripDays, setCollapsedRoadtripDays] = useState<Set<number>>(new Set())
|
|
const toggleRoadtripDay = useCallback((dayId: number) => {
|
|
setCollapsedRoadtripDays(prev => {
|
|
const next = new Set(prev)
|
|
if (!next.delete(dayId)) next.add(dayId)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
/**
|
|
* The road trip's lines, minus the days that are folded away.
|
|
*
|
|
* Folding a card takes that day off the map, which is most of what folding is for: a
|
|
* rail entry can be scrolled past, a line across the map cannot be looked away from.
|
|
* `lineDays` runs parallel to `lines`, so both are filtered in one pass and the colours
|
|
* stay lined up with what is left.
|
|
*/
|
|
const roadtripMapLines = useMemo(() => {
|
|
if (!collapsedRoadtripDays.size) return roadtripRoutes.lines
|
|
const hidden = new Set(
|
|
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
|
|
)
|
|
return roadtripRoutes.lines.filter((_, i) => !hidden.has(roadtripRoutes.lineDays[i]))
|
|
}, [roadtripRoutes.lines, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays])
|
|
|
|
/**
|
|
* The map's places with the folded cards' stops taken out.
|
|
*
|
|
* A second pass rather than a branch inside `mapPlaces`: that memo runs long before the
|
|
* road trip is routed, and the answer here needs the CHAINS — a card is a date, and
|
|
* after a night drive it holds stops stored on the day before (`nightSpill.ts`), so
|
|
* what a folded card hides is what is drawn on it, not what is filed under its day.
|
|
*
|
|
* A place still drawn on some other card stays, which is the rule the day plan's own
|
|
* declutter follows.
|
|
*/
|
|
const roadtripMapPlaces = useMemo(() => {
|
|
const plannedIds = new Set(Object.values(assignments).flat().map(a => a.place_id))
|
|
// The hotel a day sets out from or ends at is drawn with its drive whether or not a day
|
|
// still holds its stop: with that stop removed, the line and the walk to the door ended
|
|
// at a spot with no pin, where the phone's stage map has one (`stagePlaceIds`).
|
|
for (const day of roadtripRoutes.days) {
|
|
for (const stop of day.stops) if (stop.bookend) plannedIds.add(stop.placeId)
|
|
}
|
|
const plannedPlaces = mapPlaces.filter(p => plannedIds.has(p.id))
|
|
if (!collapsedRoadtripDays.size) return plannedPlaces
|
|
const hidden = new Set<number>()
|
|
for (const day of roadtripRoutes.days) {
|
|
if (!collapsedRoadtripDays.has(day.dayId)) continue
|
|
for (const stop of day.stops) if (!stop.automaticNight) hidden.add(stop.placeId)
|
|
}
|
|
for (const day of roadtripRoutes.days) {
|
|
if (collapsedRoadtripDays.has(day.dayId)) continue
|
|
for (const stop of day.stops) if (!stop.automaticNight) hidden.delete(stop.placeId)
|
|
}
|
|
return plannedPlaces.filter(p => !hidden.has(p.id))
|
|
}, [mapPlaces, assignments, roadtripRoutes.days, collapsedRoadtripDays])
|
|
|
|
// The recorded route follows the same folds as the places above, so a
|
|
// collapsed day does not leave its line behind on the map. Keyed on the joined
|
|
// dates rather than on the Set, because `days` changes identity on every store
|
|
// update and the GL overlay rebuilds its source whenever this reference moves.
|
|
const dawarichHiddenKey = useMemo(
|
|
() => collapsedDayDates(days, expandedDayIds, roadtripActive ? collapsedRoadtripDays : null).join('|'),
|
|
[days, expandedDayIds, roadtripActive, collapsedRoadtripDays],
|
|
)
|
|
const dawarichHiddenDates = useMemo(
|
|
() => (dawarichHiddenKey ? new Set(dawarichHiddenKey.split('|')) : null),
|
|
[dawarichHiddenKey],
|
|
)
|
|
|
|
const roadtripLineColors = useMemo(
|
|
() => {
|
|
if (!roadtripSettings.roadtrip_day_colors) return undefined
|
|
const hidden = new Set(
|
|
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
|
|
)
|
|
return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n))
|
|
},
|
|
[roadtripSettings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays],
|
|
)
|
|
|
|
const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => {
|
|
try {
|
|
if (!can('day_edit', trip) || !roadtripPreferencesState.ready) return
|
|
const userId = useAuthStore.getState().user?.id
|
|
if (!userId) return
|
|
const preferences = await roadtripPreferencesRepo.update(tripId, { [key]: value })
|
|
publishRoadtripPreferences(userId, tripId, preferences)
|
|
} catch {
|
|
toast.error(t('places.saveError'))
|
|
}
|
|
}, [tripId, trip, can, roadtripPreferencesState.ready, toast, t])
|
|
|
|
const saveStopDraft = useCallback(async (
|
|
{ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number },
|
|
{ releaseStay = false }: { releaseStay?: boolean } = {},
|
|
) => {
|
|
if (!stopDraft) return
|
|
const { poi, dayId, position } = stopDraft
|
|
const accommodationId = stopDraft.editing?.accommodationId
|
|
// Turning a booked night into a pause deletes the booking, and with it the
|
|
// reservation and the expense the server keeps against it. The popup stays open
|
|
// behind the question, so a no leaves the traveller exactly where they were.
|
|
if (accommodationId && !releaseStay) {
|
|
const stay = tripAccommodations.find(s => s.id === accommodationId)
|
|
const booking = reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === accommodationId)?.title
|
|
?? stay?.reservation_title ?? null
|
|
setStayRelease({ stop: { stopType, dwellMinutes }, name: poi.name, booking })
|
|
return
|
|
}
|
|
try {
|
|
if (stopDraft.editing) {
|
|
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes })
|
|
if (accommodationId) {
|
|
// The night is what was switched off, not the stop: it stays where it is
|
|
// in the drive and becomes an ordinary pause.
|
|
applyStayStops(await accommodationsApi.delete(tripId, accommodationId, { keepStop: true }))
|
|
await loadAccommodations()
|
|
}
|
|
updateRouteForDay(dayId)
|
|
setStopDraft(null)
|
|
return
|
|
}
|
|
const place = await tripActions.addPlace(tripId, {
|
|
name: poi.name,
|
|
lat: poi.lat,
|
|
lng: poi.lng,
|
|
address: poi.address || null,
|
|
website: poi.website || undefined,
|
|
phone: poi.phone || undefined,
|
|
osm_id: poi.osm_id,
|
|
duration_minutes: dwellMinutes,
|
|
stop_type: stopType,
|
|
})
|
|
if (place?.id) {
|
|
// Worked out BEFORE the stop lands, against the day as it stands and the road as
|
|
// it is currently driven — once the list has shifted there is no record of which
|
|
// leg each via was drawn for.
|
|
const plan = reanchorAfterInsert(
|
|
roadtripVias.byDay[dayId] ?? [],
|
|
position,
|
|
viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }),
|
|
)
|
|
await tripActions.assignPlaceToDay(tripId, dayId, place.id, position)
|
|
// Awaited before the day re-routes: the routing effect reads the anchors against
|
|
// the new stop list, so a correction landing after it would draw the wrong road
|
|
// first and the right one a moment later.
|
|
await roadtripVias.reanchor(dayId, plan)
|
|
updateRouteForDay(dayId)
|
|
}
|
|
setStopDraft(null)
|
|
toast.success(t('trip.toast.placeAdded'))
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, tripAccommodations, reservations])
|
|
|
|
/** The yes to the question above: the same save, this time allowed to drop the night. */
|
|
const confirmStayRelease = useCallback(async () => {
|
|
const pending = stayRelease
|
|
setStayRelease(null)
|
|
if (pending) await saveStopDraft(pending.stop, { releaseStay: true })
|
|
}, [stayRelease, saveStopDraft])
|
|
|
|
const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: {
|
|
endDayId: number
|
|
checkIn: string
|
|
checkOut: string
|
|
}) => {
|
|
if (!stopDraft) return
|
|
const { poi, dayId, position } = stopDraft
|
|
try {
|
|
if (stopDraft.editing) {
|
|
const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null }
|
|
// The answer carries the day stop the booking moved or added. The socket
|
|
// deliberately skips the session that sent the request, so without folding
|
|
// it in the one person whose rail still shows the night at its old place in
|
|
// the chain is the one who just moved its check-in.
|
|
const written = stopDraft.editing.accommodationId
|
|
? await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking)
|
|
: await accommodationsApi.create(tripId, booking)
|
|
applyStayStops(written)
|
|
// A night seated by a new check-in renumbers its neighbours as well, and the
|
|
// answer names only the night. The day is read back whole rather than guessed.
|
|
if (written?.movedAssignment) await tripActions.refreshDays(tripId)
|
|
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' })
|
|
await loadAccommodations()
|
|
updateRouteForDay(dayId)
|
|
setStopDraft(null)
|
|
return
|
|
}
|
|
const place = await tripActions.addPlace(tripId, {
|
|
name: poi.name,
|
|
lat: poi.lat,
|
|
lng: poi.lng,
|
|
address: poi.address || null,
|
|
website: poi.website || undefined,
|
|
phone: poi.phone || undefined,
|
|
osm_id: poi.osm_id,
|
|
stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel',
|
|
})
|
|
if (place?.id) {
|
|
const plan = reanchorAfterInsert(
|
|
roadtripVias.byDay[dayId] ?? [],
|
|
position,
|
|
viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }),
|
|
)
|
|
await tripActions.assignPlaceToDay(tripId, dayId, place.id, position)
|
|
await accommodationsApi.create(tripId, {
|
|
place_id: place.id,
|
|
start_day_id: dayId,
|
|
end_day_id: endDayId,
|
|
...(checkIn ? { check_in: checkIn } : {}),
|
|
...(checkOut ? { check_out: checkOut } : {}),
|
|
})
|
|
await loadAccommodations()
|
|
await roadtripVias.reanchor(dayId, plan)
|
|
updateRouteForDay(dayId)
|
|
}
|
|
setStopDraft(null)
|
|
toast.success(t('roadtrip.stay.nightAdded'))
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations])
|
|
|
|
/**
|
|
* Turns a stop on the drive into a pause, or back into a destination.
|
|
*
|
|
* The only difference between the two is `stop_type`, which decides whether the stop
|
|
* takes a number, counts in the day's total and appears in the printout. So this is one
|
|
* field on one place, and the rail redraws itself off the store the moment it lands.
|
|
*/
|
|
const setRoadtripStopKind = useCallback(async (placeId: number, kind: RoadtripStopType | null) => {
|
|
if (!can('place_edit', trip)) return
|
|
try {
|
|
await tripActions.updatePlace(tripId, placeId, { stop_type: kind })
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [tripId, tripActions, toast, t, can, trip])
|
|
|
|
/**
|
|
* How full THIS stop fills the tank, from the road trip rail.
|
|
*
|
|
* The same shape as the kind above and for the same reason: one field on one place,
|
|
* with the rail redrawing off the store the moment it lands. Null hands the stop back
|
|
* to whatever the traveller set as their own default, which is what every stop does
|
|
* until somebody has an opinion about one.
|
|
*/
|
|
const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => {
|
|
if (!can('place_edit', trip)) return
|
|
try {
|
|
await tripActions.updatePlace(tripId, placeId, { fill_percent: percent })
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [tripId, tripActions, toast, t, can, trip])
|
|
|
|
/**
|
|
* Moves a stop within its day, from the road trip rail.
|
|
*
|
|
* The rail reports only "this assignment, from here to there" and the full order is
|
|
* rebuilt here, from the day's COMPLETE assignment list rather than from what the rail
|
|
* shows. That matters: the rail hides stops without coordinates, and both
|
|
* `reorderAssignments` and the WebSocket handler rebuild the day's array purely from the
|
|
* ids they are given — anything left out would vanish from the store, for every session
|
|
* watching the trip.
|
|
*
|
|
* Within one day only. Moving between days stays in the day plan, where empty and
|
|
* one-stop days are visible and can be dropped onto; the rail leaves them out, so a day
|
|
* would disappear from under the cursor mid-gesture.
|
|
*
|
|
* No confirmation prompt for a stop with a pinned time, unlike the day plan: the rail
|
|
* recomputes the cascade immediately and marks a stop it can no longer reach in time.
|
|
* Showing the consequence is better than asking about it in advance.
|
|
*/
|
|
const reorderRoadtripStop = useCallback(async (dayId: number, assignmentId: number, toIndex: number) => {
|
|
if (!can('day_edit', trip)) return
|
|
const all = assignments[String(dayId)] ?? []
|
|
const ordered = [...all].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
const fromIdx = ordered.findIndex(a => a.id === assignmentId)
|
|
if (fromIdx === -1) return
|
|
|
|
// `toIndex` counts stops as the rail lists them; map it onto the full list, which may
|
|
// hold rows the rail never showed.
|
|
const visible = ordered.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
|
|
const target = visible[Math.min(Math.max(toIndex, 0), visible.length - 1)]
|
|
if (!target || target.id === assignmentId) return
|
|
const toIdx = ordered.findIndex(a => a.id === target.id)
|
|
if (toIdx === -1 || toIdx === fromIdx) return
|
|
|
|
const next = [...ordered]
|
|
const [moved] = next.splice(fromIdx, 1)
|
|
next.splice(toIdx, 0, moved)
|
|
|
|
// In the rail's own index space, which is the one the anchors are counted in. A stop
|
|
// without coordinates never entered that space, so moving it changes nothing there.
|
|
const fromVis = visible.findIndex(a => a.id === assignmentId)
|
|
const plan = fromVis === -1
|
|
? null
|
|
: reanchorAfterReorder(roadtripVias.byDay[dayId] ?? [], fromVis, toIndex, visible.length)
|
|
|
|
try {
|
|
await tripActions.reorderAssignments(tripId, dayId, next.map(a => a.id))
|
|
if (plan) await roadtripVias.reanchor(dayId, plan)
|
|
updateRouteForDay(dayId)
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias])
|
|
|
|
/**
|
|
* The stop whose length is being set, or null while the dialog is closed.
|
|
*
|
|
* Held here rather than in the rail because the write goes through `tripActions`, and
|
|
* the rail is a list — putting a dialog's state inside a row means it dies whenever the
|
|
* list re-renders around it.
|
|
*/
|
|
const [stayDraft, setStayDraft] = useState<StayDraft | null>(null)
|
|
|
|
/**
|
|
* Whether the day ends at this stop, from BOTH the things that can end it.
|
|
*
|
|
* A stop carries an `end_day` flag, and a day can also be closed by a manual boundary
|
|
* filed against that stop (`to_assignment_id === null`). `setRoadtripEndDay` already
|
|
* knows about both and clears whichever is set, so a surface reading only the flag shows
|
|
* a day end as off, and the tap meant to switch it on deletes the boundary instead. The
|
|
* question is asked here once rather than answered again per surface.
|
|
*/
|
|
const roadtripEndsDayAt = useCallback(
|
|
(stop: RoadtripStop): boolean =>
|
|
!!stop.endDay
|
|
|| dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId),
|
|
[dayBoundaries.boundaries],
|
|
);
|
|
|
|
/**
|
|
* Returns whether the day end actually moved.
|
|
*
|
|
* It reports rather than throws, because it shows its own toast and a second one from the
|
|
* caller would be the same news twice. A caller that flipped a switch optimistically has
|
|
* to hear about a refusal all the same, or it sits there showing a state the trip never
|
|
* reached: the phone sheet's catch was unreachable for exactly this reason.
|
|
*/
|
|
const setRoadtripEndDay = useCallback(async (stop: RoadtripStop): Promise<boolean> => {
|
|
if (!dailyTimesActive || !can('day_edit', trip)) return false
|
|
try {
|
|
const manual = dayBoundaries.boundaries.find(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId)
|
|
if (manual) {
|
|
await dayBoundaries.save(manual.day_number, null)
|
|
if (!stop.endDay) return true
|
|
}
|
|
await tripActions.setAssignmentEndDay(tripId, stop.ownerDayId, stop.assignmentId, !stop.endDay)
|
|
return true
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
return false
|
|
}
|
|
}, [dailyTimesActive, can, trip, tripActions, tripId, toast, t, dayBoundaries.boundaries, dayBoundaries.save])
|
|
|
|
/**
|
|
* How long the traveller stays at one stop.
|
|
*
|
|
* Writes `places.duration_minutes`, which the schedule has read since it was written
|
|
* and which nothing in TREK has ever been able to set — the road trip is the only place
|
|
* the value means anything, so it is the only place that edits it.
|
|
*
|
|
* Zero rather than null to clear: the update statement folds a null into "leave it
|
|
* alone" (`COALESCE(?, duration_minutes)`), so a null could give a stop a stay but
|
|
* never take one away. The rail reads zero and absent as the same thing.
|
|
*/
|
|
const setRoadtripStay = useCallback(async (placeId: number, minutes: number) => {
|
|
if (!can('place_edit', trip)) return
|
|
try {
|
|
await tripActions.updatePlace(tripId, placeId, { duration_minutes: minutes })
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [tripId, tripActions, toast, t, can, trip])
|
|
|
|
/**
|
|
* Moves a stop onto another day, from the road trip rail.
|
|
*
|
|
* Split from `reorderRoadtripStop` because it is a different call with a different
|
|
* failure mode: `moveAssignment` writes to two days, and the rail has to be able to
|
|
* reach days it draws no drive for — a day with one stop or none is exactly what a
|
|
* stop gets moved onto when a leg turns out to be too long for one day.
|
|
*
|
|
* The target index counts the stops the rail shows on that day; an empty day takes
|
|
* position 0.
|
|
*/
|
|
const moveRoadtripStopToDay = useCallback(async (
|
|
fromDayId: number,
|
|
assignmentId: number,
|
|
toDayId: number,
|
|
toIndex: number,
|
|
) => {
|
|
if (!can('day_edit', trip)) return
|
|
if (fromDayId === toDayId) return
|
|
const target = (assignments[String(toDayId)] ?? [])
|
|
.slice()
|
|
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
const visible = target.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
|
|
// Map the rail's own count onto the day's full list, which may hold rows it hides.
|
|
const anchor = visible[Math.min(Math.max(toIndex, 0), Math.max(visible.length - 1, 0))]
|
|
const at = anchor ? target.findIndex(a => a.id === anchor.id) : target.length
|
|
|
|
// Both days shift at once, and each needs its own correction: the stop leaves a gap
|
|
// on one side and opens one on the other. No geometry is measured for the arriving
|
|
// day — its roads are about to be different anyway, so there is nothing stable to
|
|
// measure a via against.
|
|
const fromStops = roadtripStopsOf(fromDayId)
|
|
const fromVis = fromStops.findIndex(a => a.id === assignmentId)
|
|
const fromPlan = fromVis === -1
|
|
? null
|
|
: reanchorAfterRemove(roadtripVias.byDay[fromDayId] ?? [], fromVis, fromStops.length)
|
|
const toVis = Math.min(Math.max(toIndex, 0), visible.length)
|
|
const toPlan = fromVis === -1
|
|
? null
|
|
: reanchorAfterInsert(roadtripVias.byDay[toDayId] ?? [], toVis, () => true)
|
|
|
|
try {
|
|
await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, at < 0 ? target.length : at)
|
|
if (fromPlan) await roadtripVias.reanchor(fromDayId, fromPlan)
|
|
if (toPlan) await roadtripVias.reanchor(toDayId, toPlan)
|
|
updateRouteForDay(fromDayId)
|
|
updateRouteForDay(toDayId)
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias, roadtripStopsOf])
|
|
|
|
const routeAlternatives = useRouteAlternatives()
|
|
/**
|
|
* Which offered route the pointer is on, so the map can light that one up.
|
|
*
|
|
* Lives here rather than in the bar because the map draws it and the bar reports it —
|
|
* neither owns it, and passing it through the page would put state in a wiring
|
|
* container the Page pattern keeps stateless.
|
|
*/
|
|
const [highlightedAlternative, setHighlightedAlternative] = useState<number | null>(null)
|
|
// Closing the picker has to clear it, or the next one opens with a road already lit.
|
|
useEffect(() => {
|
|
if (!routeAlternatives.open) setHighlightedAlternative(null)
|
|
}, [routeAlternatives.open])
|
|
|
|
// Leaving road trip mode closes it too. The switch sits in the left sidebar and
|
|
// is reachable while the bar is open over the map, and the overlay depends only
|
|
// on the picker — so flipping the mode off left pale blue alternatives, their
|
|
// casings and their drive-time pills drawn on an ordinary planner map, with no
|
|
// road trip UI left to dismiss them from.
|
|
//
|
|
// The gate is where the picker can be seen, not the mode. `roadtripActive` is false
|
|
// on a phone by design (see `roadtripMode`), so gating on it alone closed a picker
|
|
// the phone had just opened, on the very next render. On the phone the picker lives
|
|
// on the drive tab, so it stays open there and closes once the tab is left. At desk
|
|
// width `isMobile` is false and this is exactly `roadtripActive`, as it always was.
|
|
const alternativesShown = roadtripActive || (isMobile && roadtripFeedActive && activeTab === 'roadtrip')
|
|
useEffect(() => {
|
|
if (!alternativesShown) routeAlternatives.close()
|
|
}, [alternativesShown, routeAlternatives])
|
|
|
|
/**
|
|
* The offered routes as the map draws them: line, colour, and the label that sits on
|
|
* the road. Built here rather than in the page so the page stays a wiring container
|
|
* and both renderers get the identical shape.
|
|
*/
|
|
const alternativeOverlays = useMemo(
|
|
() => buildAlternativeOverlays(routeAlternatives.open?.routes, {
|
|
fastest: t('roadtrip.alt.fastest'),
|
|
current: t('roadtrip.alt.current'),
|
|
noMotorway: t('roadtrip.alt.noMotorway'),
|
|
noToll: t('roadtrip.alt.noToll'),
|
|
noFerry: t('roadtrip.alt.noFerry'),
|
|
}, routeAlternatives.open?.engine),
|
|
[routeAlternatives.open, t],
|
|
)
|
|
|
|
/**
|
|
* The stretch of map the offered routes cover, handed to whichever renderer is up.
|
|
*
|
|
* Opening the picker without moving the camera means weighing three roads you cannot
|
|
* see. Derived from the overlays rather than from the two endpoints so the frame holds
|
|
* the whole of every alternative, including one that swings far off the direct line.
|
|
* Empty while nothing is open, and the map is told to do nothing with an empty list —
|
|
* so closing the picker leaves the view where the user put it.
|
|
*/
|
|
const alternativeFocusPoints = useMemo(
|
|
() => alternativeOverlays.flatMap(o => o.coordinates),
|
|
[alternativeOverlays],
|
|
)
|
|
|
|
/**
|
|
* What the map should bring into view.
|
|
*
|
|
* Refuel offers win while they are open, and for the reason they exist at all: somebody
|
|
* is being asked to accept a stop, and a stop off the edge of the map cannot be judged.
|
|
* They are the newer, smaller and more specific answer, so they take the view from the
|
|
* alternatives rather than being averaged with them into a frame that shows neither.
|
|
*/
|
|
const automaticPoints = useAutomaticDayPoints(roadtripRoutes, collapsedRoadtripDays)
|
|
const focusRoadtripPoint = useCallback((lat: number, lng: number) => {
|
|
refuel.close()
|
|
routeAlternatives.close()
|
|
automaticPoints.focusPoint(lat, lng)
|
|
}, [refuel, routeAlternatives, automaticPoints])
|
|
const roadtripMapVias = automaticPoints.markers
|
|
const dayBoundaryControls = useMemo<DayBoundaryControls | undefined>(() => {
|
|
if (!dayBoundaries.editable || !can('day_edit', trip) || !roadtripRoutes.boundaryPath?.length) return undefined
|
|
return {
|
|
path: roadtripRoutes.boundaryPath,
|
|
hint: t('roadtrip.window.dragHint'),
|
|
move: async (day, boundary) => {
|
|
const next = dayBoundaries.boundaries.filter(b => b.day_number !== day)
|
|
if (boundary) next.push(boundary)
|
|
const issue = roadtripRoutes.validateBoundaries?.(next)
|
|
if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`, { days: MAX_TRIP_DAYS })); return false }
|
|
try { return await dayBoundaries.save(day, boundary) }
|
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')); return false }
|
|
},
|
|
}
|
|
}, [dayBoundaries.editable, dayBoundaries.boundaries, dayBoundaries.save, can, trip, roadtripRoutes, t, toast])
|
|
const mapFocusPoints = useMemo<[number, number][]>(
|
|
() => (refuel.offered.length
|
|
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
|
|
: alternativeFocusPoints.length ? alternativeFocusPoints : automaticPoints.focusPoints ?? alternativeFocusPoints),
|
|
[refuel.offered, alternativeFocusPoints, automaticPoints.focusPoints],
|
|
)
|
|
|
|
/**
|
|
* The rail as it stands now, for a choice that is written several router answers after
|
|
* the render that started it. Checked against before anything is written.
|
|
*/
|
|
const railDaysRef = useRef<Parameters<typeof railLegAt>[0]>(roadtripRoutes.days)
|
|
// The days with a single stop as well, in day order: they draw no card, but a drive
|
|
// into the day after one leaves from its stop, and a choice for that drive is filed there.
|
|
useEffect(() => {
|
|
railDaysRef.current = [...roadtripRoutes.days, ...roadtripRoutes.quietDays].sort((a, b) => a.dayNumber - b.dayNumber)
|
|
}, [roadtripRoutes.days, roadtripRoutes.quietDays])
|
|
|
|
/**
|
|
* Asks the rail's own router for other ways of one drive on a card.
|
|
*
|
|
* The picker is handed everything about the leg as the rail has it: the road it is on
|
|
* now, which heads the list as the current one; where a choice would be written; and
|
|
* the router with the leg's own mode and avoided classes, not the trip-wide profile.
|
|
*
|
|
* The drive arriving at the head of a connected card is asked about the same way, as
|
|
* the pair from the last stop of the day before to the card's first. Its router is the
|
|
* one the rail drives that seam with, under the card it arrives on, and a choice is
|
|
* filed behind the stop it leaves, where the map already files a point dropped on it.
|
|
* It used to be the one drive on the rail that could not be offered another way.
|
|
*/
|
|
const askRouteAlternatives = useCallback((dayId: number, drive: RailDrive) => {
|
|
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
|
const found = day ? railDriveOn(day, drive) : null
|
|
const router = found ? roadtripRoutes.legRouter?.(found.from, found.to, dayId) : undefined
|
|
if (!found || !router) return
|
|
if (openOn(routeAlternatives.open, dayId, drive)) {
|
|
routeAlternatives.close()
|
|
return
|
|
}
|
|
const { from, to, seg, line } = found
|
|
routeAlternatives.ask({
|
|
dayId,
|
|
drive,
|
|
from: { lat: from.lat, lng: from.lng },
|
|
to: { lat: to.lat, lng: to.lng },
|
|
driven: { coordinates: line ?? [], distance: seg.distance, duration: seg.duration },
|
|
// The vias of a leg are filed behind the stop it leaves, on the day that stop is
|
|
// stored on, which on a card holding a night drive or a drive in from yesterday is
|
|
// not the card's own day.
|
|
anchor: { dayId: from.ownerDayId, afterIndex: from.ownerIndex },
|
|
ends: { from: from.assignmentId, to: to.assignmentId },
|
|
router,
|
|
})
|
|
}, [roadtripRoutes, routeAlternatives])
|
|
|
|
/**
|
|
* Taking one of the offered routes: pinned, proven, and only then written.
|
|
*
|
|
* Saved as vias, not as a stored polyline: a polyline goes stale with the next OSM update
|
|
* and with every stop that moves, while a via keeps forcing the router back onto this
|
|
* road for as long as the road exists. But a via only holds a road the router is willing
|
|
* to drive through it, and nothing used to check that: a point on a ferry was pulled to
|
|
* the pier and the day went the long way round, and a way weighed away from motorways
|
|
* kept the motorway after its one pinned point. So the rail's own router is asked first
|
|
* (`pinAlternative`), and a way it will not follow is not saved and says why.
|
|
*
|
|
* The pins replace the leg's vias in one write rather than joining them. Appending put a
|
|
* new point behind the old one and routed out to each in turn, a zigzag matching neither
|
|
* the preview nor the distance printed on it; and one delete per via meant a full re-route
|
|
* between each of them. A write that fails is reported and leaves the picker open:
|
|
* swallowed, it closed on a leg that still carried its via, and not even the reload ran
|
|
* to contradict the traveller.
|
|
*
|
|
* A refusal is said twice: as a toast, and to the bar (`settle`), which keeps it beside
|
|
* the offers and announces it. A leg the rail drew with OSRM standing in for an engine
|
|
* that did not answer is asked for again once a choice holds, since the road the router
|
|
* chose is not the one on the map: taking the router's own road there wrote nothing and
|
|
* left the stand-in line in place, which read as a click that did nothing.
|
|
*/
|
|
const chooseRouteAlternative = useCallback(async (index: number) => {
|
|
const open = routeAlternatives.open
|
|
const offer = open?.routes[index]
|
|
if (!open || !offer) return
|
|
// Choosing the road already being driven changes nothing.
|
|
if (offer.current) { routeAlternatives.close(); return }
|
|
// One choice at a time: the map line can be clicked while a chip's choice is checked.
|
|
if (alternativesBusy(open)) return
|
|
// Vias are written online only, so a choice that could not be kept is not checked.
|
|
if (!roadtripVias.editable) { toast.error(t('roadtrip.alt.offline')); return }
|
|
|
|
const signal = routeAlternatives.prove(index)
|
|
let proof: PinProof
|
|
try {
|
|
proof = await pinAlternative({ offer, current: open.routes.find(r => r.current), route: open.route, signal })
|
|
} catch {
|
|
if (signal.aborted) return
|
|
routeAlternatives.settle(t('roadtrip.alt.failed'))
|
|
// The desk's bar says it in its own status line; a toast on top covered that line
|
|
// and printed the same sentence twice. The phone's bar has no room for a sentence.
|
|
if (isMobile) toast.error(t('roadtrip.alt.failed'))
|
|
return
|
|
}
|
|
if (signal.aborted) return
|
|
if (!proof.held) {
|
|
const refusal = t(proof.fellBack ? 'roadtrip.alt.failed' : 'roadtrip.alt.notHeld')
|
|
const hint = proof.fellBack ? null : refusalHint(offer, proof.last, t)
|
|
routeAlternatives.settle(hint ? `${refusal} ${hint}` : refusal)
|
|
if (isMobile) {
|
|
toast.error(refusal, 6000)
|
|
if (hint) toast.info(hint, 8000)
|
|
}
|
|
return
|
|
}
|
|
|
|
// The chain may have moved while the router was asked: a stop dragged, a collaborator's
|
|
// edit arriving. Pins worked out for this leg are only written where it still runs.
|
|
const { anchor, ends } = open
|
|
const leg = railLegAt(railDaysRef.current, anchor)
|
|
if (!leg || leg.from.assignmentId !== ends.from || leg.to.assignmentId !== ends.to) {
|
|
toast.error(t('roadtrip.alt.legChanged'), 6000)
|
|
routeAlternatives.close()
|
|
return
|
|
}
|
|
// The router's own road on a leg nothing bends is already what is driven, unless OSRM
|
|
// drew the leg in its engine's place. A write routes the leg again by itself; without
|
|
// one the rail is asked to, and either way the picker says why the map may still hold
|
|
// the stand-in line for a moment, or for as long as that engine does not answer.
|
|
const bent = viasLeaving(leg.from, roadtripVias.byDay[anchor.dayId] ?? []).length > 0
|
|
if (proof.pins.length || bent) {
|
|
try {
|
|
await roadtripVias.addMany(
|
|
anchor.dayId,
|
|
proof.pins.map(pin => ({ after_order_index: anchor.afterIndex, lat: pin.lat, lng: pin.lng })),
|
|
[anchor.afterIndex],
|
|
)
|
|
} catch (err: unknown) {
|
|
// Said even when the picker has moved on in the meantime: the write was asked for.
|
|
const message = err instanceof Error ? err.message : t('common.unknownError')
|
|
if (!signal.aborted) routeAlternatives.settle(message)
|
|
toast.error(message)
|
|
return
|
|
}
|
|
} else if (open.standIn) {
|
|
roadtripRoutes.reroute?.()
|
|
}
|
|
if (open.standIn) toast.info(t('roadtrip.alt.standIn'), 8000)
|
|
// A picker opened on another leg while this was written belongs to that leg now.
|
|
if (!signal.aborted) routeAlternatives.close()
|
|
}, [routeAlternatives, roadtripVias, roadtripRoutes, toast, t, isMobile])
|
|
|
|
/**
|
|
* A click on the drawn route puts a via there, and the drive is redrawn through it.
|
|
*
|
|
* Which pair of stops it belongs between comes from projecting the click onto the
|
|
* day's routed geometry — the same measurement the corridor search uses, so "after the
|
|
* third stop" means the same thing everywhere. The day is the one whose line was hit,
|
|
* found by trying each day's geometry and keeping the closest.
|
|
*/
|
|
/**
|
|
* Which stop of which day a point belongs behind, measured along the drive.
|
|
*
|
|
* Shared by placing a via and by dragging one, because it is the same question both
|
|
* times and the answer has to be recomputed both times. A drag used to send only the
|
|
* new coordinates, so a via pulled past the stop it used to precede kept claiming the
|
|
* earlier leg: the route then ran out to the point and back before carrying on, which
|
|
* looks exactly like a drag that did nothing.
|
|
*/
|
|
const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => {
|
|
// `terminal` names the anchor when it is one end of a ride rather than a stored stop
|
|
// (#2428). Nothing can be filed against a terminal: it stands in for no assignment,
|
|
// so a via anchored to it would be stored at a position that belongs to the stop
|
|
// after it and bend that stop's road instead. The callers decide what to refuse.
|
|
//
|
|
// `bookendLeg` says the drive leaves or reaches a booked night's hotel at the day's
|
|
// edge, which files nothing either: the morning's hotel has no index of its own, and
|
|
// the evening's drive is reached from the index that shapes the road into tomorrow.
|
|
// `card` is where the point fell on the card it was measured on, the day and the index
|
|
// of the stop before it there, -1 for the drive in before the card's first stop.
|
|
type Anchor = {
|
|
dayId: number
|
|
afterIndex: number
|
|
offRouteKm: number
|
|
terminal: CarrierTerminal['role'] | null
|
|
bookendLeg: boolean
|
|
card: { dayId: number; index: number }
|
|
}
|
|
// A road the day drives twice, out of the hotel in the morning and past it again later,
|
|
// is on the line twice, and the closer pass wins by metres at most. A pass that can
|
|
// take a via beats one on the hotel's drive, which files nothing, by this much.
|
|
const SAME_ROAD_KM = 0.03
|
|
const beats = (c: Anchor, b: Anchor | null) => {
|
|
if (!b) return true
|
|
if (c.bookendLeg === b.bookendLeg) return c.offRouteKm < b.offRouteKm
|
|
return c.bookendLeg ? c.offRouteKm + SAME_ROAD_KM < b.offRouteKm : c.offRouteKm <= b.offRouteKm + SAME_ROAD_KM
|
|
}
|
|
// Which stop of this card the point falls behind, for a hit measured on its line.
|
|
const readAnchor = (day: (typeof roadtripRoutes.days)[number], hit: CorridorHit, stopsAlong: number[]): Anchor | null => {
|
|
// Before the card's first stop means a drive that arrives here but leaves from a
|
|
// stop on the card BEFORE this one: the incoming night drive (`nightSpill.ts`), or
|
|
// on a trip with connected days the drive from where yesterday ended, which is drawn
|
|
// at the head of this card in yesterday's colour. Anchoring either to this card's
|
|
// first stop would file the via on the leg AFTER that stop, and the route would run
|
|
// forward, double back to the point, and carry on.
|
|
//
|
|
// Asked of the distance rather than of the index, because the index cannot answer
|
|
// it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and
|
|
// the rail only ever publishes cards with two stops or more. Written against the
|
|
// index this read as a guard and behaved as dead code, so a via dropped on the
|
|
// night stretch went to the first drawn stop after all, which is the exact failure
|
|
// the paragraph above describes.
|
|
const arrivedFrom = day.spills?.find(sp => sp.at === 0)?.fromStop ?? day.arrivingFrom
|
|
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
|
|
const owner = arrivedFrom.ownerDayId ?? day.dayId
|
|
if (onlyDayId !== undefined && owner !== onlyDayId) return null
|
|
return {
|
|
dayId: owner,
|
|
afterIndex: arrivedFrom.ownerIndex ?? 0,
|
|
offRouteKm: hit.offRouteKm,
|
|
terminal: arrivedFrom.carrier?.role ?? null,
|
|
bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend,
|
|
card: { dayId: day.dayId, index: -1 },
|
|
}
|
|
}
|
|
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
|
|
// Named by the day the anchor stop is STORED on and its position there, not by the
|
|
// card and the position within it. A card is a date and can hold stops from the day
|
|
// before (`nightSpill.ts`), so those two numbers differ on any day that received a
|
|
// night drive — and a via filed under the card's numbers matches no stop when the
|
|
// route is next built, which reads as a drag that did nothing at all.
|
|
const anchor = day.stops[at]
|
|
if (!anchor) return null
|
|
// Falling back to the card's own numbers is not a guard against a bug, it is the
|
|
// meaning: a stop that names no other day IS stored on the card it is drawn on,
|
|
// which is every stop on a trip that never drives past midnight.
|
|
const owner = anchor.ownerDayId ?? day.dayId
|
|
// A drag stays on its own day; a fresh click may land wherever it landed.
|
|
if (onlyDayId !== undefined && owner !== onlyDayId) return null
|
|
return {
|
|
dayId: owner,
|
|
afterIndex: anchor.ownerIndex ?? at,
|
|
offRouteKm: hit.offRouteKm,
|
|
terminal: anchor.carrier?.role ?? null,
|
|
bookendLeg: !!anchor.bookend || !!day.stops[at + 1]?.bookend,
|
|
card: { dayId: day.dayId, index: at },
|
|
}
|
|
}
|
|
let best: Anchor | null = null
|
|
for (const day of roadtripRoutes.days) {
|
|
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
|
|
// on, but that day's stops are no longer all on the card of the same name: after a
|
|
// night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card
|
|
// measured the new position against a line that no longer covers those stops — a
|
|
// point dragged near Brandenburg was projected onto the short remainder of card 1
|
|
// and came back anchored to its last stop, which put the via on the night drive
|
|
// itself and pushed the stop before it over midnight.
|
|
//
|
|
// So every card is measured, and the answer is filtered by the day the ANCHOR is
|
|
// stored on. Same promise, kept against the stops rather than against the card.
|
|
if (day.geometry.length < 2) continue
|
|
const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln }))
|
|
const hit = projectOntoRoute({ lat, lng }, spine)
|
|
if (!hit) continue
|
|
if (best && hit.offRouteKm >= best.offRouteKm + SAME_ROAD_KM) continue
|
|
// Which stop the via follows: the last one the car passes before reaching it.
|
|
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
|
|
let candidate = readAnchor(day, hit, stopsAlong)
|
|
if (candidate?.bookendLeg) {
|
|
// Landed on the drive to or from the hotel, a road the day can drive again between
|
|
// two of its own stops. Asked once more of the stretch between the day's first and
|
|
// last stop, and taken when that answer lies on the same road.
|
|
const first = day.stops.findIndex(stop => !stop.bookend)
|
|
const last = day.stops.length - 1 - [...day.stops].reverse().findIndex(stop => !stop.bookend)
|
|
const fromKm = stopsAlong[first] ?? 0
|
|
const toKm = stopsAlong[last] ?? 0
|
|
const inner = first >= 0 && first < last && fromKm < toKm
|
|
? projectOntoRoute({ lat, lng }, spine, { fromKm, toKm })
|
|
: null
|
|
const retry = inner && inner.offRouteKm <= hit.offRouteKm + SAME_ROAD_KM ? readAnchor(day, inner, stopsAlong) : null
|
|
if (retry && !retry.bookendLeg) candidate = retry
|
|
}
|
|
if (candidate && beats(candidate, best)) best = candidate
|
|
}
|
|
return best
|
|
}, [roadtripRoutes.days])
|
|
|
|
/**
|
|
* Where a place chosen by hand belongs in the drive, as a card and a position in it.
|
|
*
|
|
* `anchorFor` answers in the space a via is STORED in: the day the anchor stop belongs
|
|
* to, and its index there. A stop is placed at a position counted along the card it is
|
|
* drawn on, which is the same thing on every day that does not drive past midnight and
|
|
* a different one on the days that do (`nightSpill.ts`). Translating between the two
|
|
* happens here, once, rather than at whichever surface asked.
|
|
*
|
|
* Deliberately with no distance limit, unlike `addRoadtripVia`: the place this answers
|
|
* for is the charger the corridor search did not find, which is exactly the one sitting
|
|
* further off the drawn line than a via is allowed to be. The projection is the default
|
|
* the dialog offers, never a gate it applies.
|
|
*/
|
|
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
|
|
const anchor = anchorFor(lat, lng)
|
|
// Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk
|
|
// is a road, and a stop there is the first stop after landing or after the pick-up.
|
|
if (!anchor || anchor.terminal === 'departure') return null
|
|
// Behind a terminal, and on the drive from the hotel a day sets out from or to the one
|
|
// it ends at, the place goes where it fell on the card. None of them is a stored stop
|
|
// to be found by its index, which each shares with one: after the morning's hotel is
|
|
// before the day's first stop, before the evening's is after its last.
|
|
if (anchor.bookendLeg || anchor.terminal) {
|
|
return { dayId: anchor.card.dayId, position: anchor.card.index + 1, offRouteKm: anchor.offRouteKm }
|
|
}
|
|
for (const day of roadtripRoutes.days) {
|
|
// The stored stop the anchor names, not a terminal or a hotel seated in front of it
|
|
// with the same index, which put the place one stop early.
|
|
const at = day.stops.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
|
if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm }
|
|
}
|
|
// No card draws that stop, which happens while the rail is between rebuilds. Its own
|
|
// numbers are the best answer there is, and `roadtripInsertion` measures them against
|
|
// the card again when the stop actually lands.
|
|
return { dayId: anchor.dayId, position: anchor.afterIndex + 1, offRouteKm: anchor.offRouteKm }
|
|
}, [anchorFor, roadtripRoutes.days])
|
|
|
|
/**
|
|
* 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])
|
|
|
|
/**
|
|
* 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<ServiceStopMode | null>(() => {
|
|
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])
|
|
|
|
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
|
|
if (!can('day_edit', trip)) return
|
|
const best = anchorFor(lat, lng)
|
|
// A click that landed on some other line is not a via anywhere. Neither is one on a
|
|
// ride, or on the road out of a terminal: a via is filed by the position of a stored
|
|
// stop, and a terminal is not one.
|
|
if (!best || best.offRouteKm > 2 || best.terminal) return
|
|
// Nor on the drive from or to a booked night's hotel, which says so rather than
|
|
// ignoring the click (`legReroutable`).
|
|
if (best.bookendLeg) { toast.info(t('roadtrip.bookend.noVia')); return }
|
|
try {
|
|
await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [anchorFor, roadtripVias, can, trip, toast, t])
|
|
|
|
/** Dragging a via redraws the route through its new position. */
|
|
const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
|
|
if (!can('day_edit', trip)) return
|
|
// Measured against this day only. A drag is a drag WITHIN a day: letting the nearest
|
|
// day win, the way placing one does, would hand the via to a neighbouring day whose
|
|
// road happens to pass closer, and it would vanish from the day it was dragged in.
|
|
//
|
|
// No distance guard either. Dragging a via well off the current road is the whole
|
|
// point of dragging it, and refusing that would be refusing the gesture; the anchor
|
|
// just says which leg gets bent, and the router answers the rest.
|
|
const anchor = anchorFor(lat, lng, dayId)
|
|
try {
|
|
await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal && !anchor.bookendLeg ? anchor.afterIndex : undefined)
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [anchorFor, roadtripVias, can, trip, toast, t])
|
|
|
|
/**
|
|
* Somewhere to fill up before this tank runs out.
|
|
*
|
|
* Measured against the day's DRIVING line, the same one the dry point was placed on, so
|
|
* a station's distance along the road is comparable with the distance the fuel lasts.
|
|
* The day's own places go in as well: a pump already on the plan should not be offered
|
|
* beside itself.
|
|
*/
|
|
const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => {
|
|
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
|
if (!day) return
|
|
const line = (dry.inboundLine ?? day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
|
|
if (line.length < 2) return
|
|
const vehicle = roadtripSettings.roadtrip_vehicle
|
|
const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null)
|
|
let fromAlongKm = 0
|
|
let drivenKm = 0
|
|
for (let i = 0; i <= dry.legIndex; i++) {
|
|
if (refuelTypes.includes(day.stops[i]?.stopType as 'fuel' | 'charging')) fromAlongKm = drivenKm
|
|
const leg = day.legs[i]
|
|
if (leg?.mode === 'driving') drivenKm += (leg.distance ?? 0) / 1000
|
|
}
|
|
void refuel.ask(
|
|
`${dayId}:${dry.legIndex}`,
|
|
{ lat: dry.lat, lng: dry.lng },
|
|
line,
|
|
dry.drivenMeters / 1000,
|
|
day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })),
|
|
fromAlongKm,
|
|
)
|
|
}, [roadtripRoutes.days, refuel, roadtripSettings.roadtrip_vehicle])
|
|
|
|
/**
|
|
* Accepting one hands it to the same popup a corridor hit goes through.
|
|
*
|
|
* Deliberately not a direct write: the popup is where the stop kind and the time spent
|
|
* are decided, it defaults both from the category, and every step after it — the place,
|
|
* the assignment at the right position, the via re-anchoring, the re-route — is already
|
|
* correct there and pinned by tests. A second path to the same end would be a second
|
|
* place for it to go wrong.
|
|
*/
|
|
const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => {
|
|
if (!can('day_edit', trip)) return
|
|
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
|
if (!day) return
|
|
// Before the stop the tank would have run out on, which is the leg the dry point
|
|
// names. A station reached after the day's last stop is tomorrow's problem, and
|
|
// clamping it onto the final leg would re-route the arrival through it.
|
|
let drivenKm = 0
|
|
const stationLeg = day.legs.findIndex(leg => {
|
|
if (leg?.mode !== 'driving') return false
|
|
drivenKm += (leg.distance ?? 0) / 1000
|
|
return poi.alongKm <= drivenKm
|
|
})
|
|
const at = dry.inboundLine ? -dry.legIndex - 1 : Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1)
|
|
// That index counts along the CARD, and after a night drive a card is not one stored
|
|
// day: its first stops belong to yesterday. The new stop goes in front of the one it
|
|
// was measured against, so it is that stop's own day and position that place it —
|
|
// written against the card's day it would land in the wrong list, at an index that
|
|
// means something else there.
|
|
const anchor = day.stops[at]
|
|
if (!anchor) return
|
|
refuel.close()
|
|
setStopDraft({ poi, ...roadtripInsertion(day, at)!, dayNumber: day.dayNumber })
|
|
}, [roadtripRoutes.days, refuel, can, trip])
|
|
|
|
/** Removing a via lets the drive take the direct road again. */
|
|
const removeRoadtripVia = useCallback(async (dayId: number, id: number) => {
|
|
if (!can('day_edit', trip)) return
|
|
try {
|
|
await roadtripVias.remove(dayId, id)
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}, [roadtripVias, can, trip, toast, t])
|
|
|
|
/**
|
|
* A corridor hit dropped on the map, placed where it was dropped rather than where the
|
|
* corridor projected it.
|
|
*
|
|
* The two differ whenever a drive passes near the same spot twice — a loop, an
|
|
* out-and-back — and the automatic projection can only pick one of them. Dropping says
|
|
* which, and the drop coordinate is projected onto the same routed line the hits were
|
|
* measured along, so the answer is in the same units as everything else.
|
|
*
|
|
* A drop nowhere near the drive is ignored rather than guessed at: adding a stop
|
|
* fifty kilometres off the route because the pointer slipped is worse than nothing
|
|
* happening.
|
|
*/
|
|
const dropPoiOnRoute = useCallback((osmId: string, lat: number, lng: number) => {
|
|
if (!can('place_edit', trip)) return
|
|
const hit = roadtripCorridor.visible.find(p => p.osm_id === osmId)
|
|
const day = roadtripCorridor.day
|
|
if (!hit || !day) return
|
|
const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine)
|
|
if (!at || at.offRouteKm > roadtripCorridor.widthKm) return
|
|
const insert = roadtripInsertion(day, roadtripCorridor.insertIndexFor(at))
|
|
if (!insert) return
|
|
setStopDraft({
|
|
poi: hit,
|
|
...insert,
|
|
dayNumber: day.dayNumber,
|
|
})
|
|
}, [roadtripCorridor, can, trip])
|
|
|
|
/** 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])
|
|
|
|
/**
|
|
* 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 = useCallback(async (data) => {
|
|
const pendingFiles = data._pendingFiles
|
|
delete data._pendingFiles
|
|
// Where a service stop added by hand belongs on the drive. The form worked it out
|
|
// from the coordinates being saved, because until a place was chosen in it there
|
|
// were none to project.
|
|
const serviceStop = data._serviceStop
|
|
delete data._serviceStop
|
|
if (editingPlace) {
|
|
// Always strip time fields from place update — time is per-assignment only.
|
|
// Same for the day-specific note (#2163): it belongs to the assignment,
|
|
// never to the pool place.
|
|
const { place_time, end_time, assignment_notes, ...placeData } = data
|
|
await tripActions.updatePlace(tripId, editingPlace.id, placeData)
|
|
// If editing from assignment context, save time per-assignment
|
|
if (editingAssignmentId) {
|
|
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
|
|
// The form only includes assignment_notes when the user changed it, so
|
|
// an untouched note never produces a PUT (#2163). '' clears like null.
|
|
if (assignment_notes !== undefined) {
|
|
await assignmentsApi.updateNotes(tripId, editingAssignmentId, { notes: assignment_notes || null })
|
|
}
|
|
await tripActions.refreshDays(tripId)
|
|
}
|
|
// Upload pending files with place_id
|
|
if (pendingFiles?.length > 0) {
|
|
for (const file of pendingFiles) {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
fd.append('place_id', String(editingPlace.id))
|
|
try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) }
|
|
}
|
|
}
|
|
toast.success(t('trip.toast.placeUpdated'))
|
|
return { id: editingPlace.id }
|
|
} else {
|
|
const place = await tripActions.addPlace(tripId, data)
|
|
// A card of the rail can draw stops that are STORED on the day before it
|
|
// (`nightSpill.ts`), so a leg named by the card and the position in it is
|
|
// translated once here, the same way every other path into this write is.
|
|
const card = serviceStop && roadtripRoutes.days.find(day => day.dayId === serviceStop.dayId)
|
|
const insert = serviceStop
|
|
? (card && roadtripInsertion(card, serviceStop.position)) || { dayId: serviceStop.dayId, position: serviceStop.position }
|
|
: null
|
|
const dayId = insert ? insert.dayId : placeFormDayId
|
|
const position = insert ? insert.position : placeFormPosition
|
|
// Added from inside a day? Then it belongs to that day. Without this the
|
|
// place drops into the unplanned pool and, on mobile, into a different
|
|
// screen entirely — which reads as "it wasn't saved" (#1998).
|
|
if (place?.id && dayId != null) {
|
|
// Worked out BEFORE the stop lands, against the day as it stands and the road as
|
|
// it is currently driven: once the list has shifted there is no record of which
|
|
// leg each via was drawn for. A via is stored as (day, after_order_index) and
|
|
// that index is a POSITION in the day's stop list, so a stop dropped into the
|
|
// middle of a routed day pushes every via at or behind it onto the wrong leg and
|
|
// the drawn road runs forward, doubles back and runs out again. Keyed on the
|
|
// position rather than on the service-stop form: a corridor hit handed to the
|
|
// full form carries its position too, and was the one way into the middle of a
|
|
// day that left the vias where they were.
|
|
const plan = position != null && typeof data.lat === 'number' && typeof data.lng === 'number'
|
|
? reanchorAfterInsert(
|
|
roadtripVias.byDay[dayId] ?? [],
|
|
position,
|
|
viaLiesBefore(dayId, { lat: data.lat, lng: data.lng }),
|
|
)
|
|
: null
|
|
try {
|
|
// With a position the stop lands where it will be driven past, not at the end
|
|
// of the day. The slice has taken one all along; nothing ever passed it.
|
|
await tripActions.assignPlaceToDay(tripId, dayId, place.id, position)
|
|
// Awaited before the day re-routes: the routing effect reads the anchors against
|
|
// the new stop list, so a correction landing after it would draw the wrong road
|
|
// first and the right one a moment later.
|
|
if (plan) await roadtripVias.reanchor(dayId, plan)
|
|
updateRouteForDay(dayId)
|
|
} catch (err: unknown) {
|
|
// The place itself exists; only the day link failed.
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
}
|
|
}
|
|
if (pendingFiles?.length > 0 && place?.id) {
|
|
for (const file of pendingFiles) {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
fd.append('place_id', String(place.id))
|
|
try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) }
|
|
}
|
|
}
|
|
toast.success(t('trip.toast.placeAdded'))
|
|
if (place?.id) {
|
|
const capturedId = place.id
|
|
pushUndo(t('undo.addPlace'), async () => {
|
|
await tripActions.deletePlace(tripId, capturedId)
|
|
})
|
|
}
|
|
// Handed back so the form can link an expense to a place that did not
|
|
// exist a moment ago (#1298), the same way the booking modals work.
|
|
return place?.id ? { id: place.id } : undefined
|
|
}
|
|
}, [editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, roadtripRoutes.days, tripId, toast, pushUndo, updateRouteForDay, 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 (!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)
|
|
}, [can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
|
|
|
/**
|
|
* How long the drive stands here, for every stop alike.
|
|
*
|
|
* A booked night used to be sent to the booking form instead, because its duration was
|
|
* read off the check-out and there was nothing here to set. The drive no longer reads
|
|
* a check-out at all: a night is a stop that takes as long as it takes, and asking how
|
|
* long is the same question at a hotel as at a viewpoint.
|
|
*/
|
|
const editRoadtripStay = useCallback((draft: NonNullable<typeof stayDraft>) => {
|
|
setStayDraft(draft)
|
|
}, [])
|
|
|
|
const handleDeletePlace = useCallback((placeId) => {
|
|
if (!can('place_edit', trip)) return
|
|
setDeletePlaceId(placeId)
|
|
}, [can, trip])
|
|
|
|
const confirmDeletePlace = useCallback(async () => {
|
|
if (!deletePlaceId) return
|
|
const state = useTripStore.getState()
|
|
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
|
|
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
|
|
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
|
|
)
|
|
try {
|
|
await tripActions.deletePlace(tripId, deletePlaceId)
|
|
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
|
updateRouteForDay(selectedDayId)
|
|
toast.success(t('trip.toast.placeDeleted'))
|
|
if (capturedPlace) {
|
|
pushUndo(t('undo.deletePlace'), async () => {
|
|
const newPlace = await tripActions.addPlace(tripId, {
|
|
name: capturedPlace.name,
|
|
description: capturedPlace.description,
|
|
lat: capturedPlace.lat,
|
|
lng: capturedPlace.lng,
|
|
address: capturedPlace.address,
|
|
category_id: capturedPlace.category_id,
|
|
price: capturedPlace.price,
|
|
// An undone track has to come back as a track, not a bare point.
|
|
route_geometry: capturedPlace.route_geometry,
|
|
route_color: capturedPlace.route_color,
|
|
})
|
|
for (const { dayId, orderIndex } of capturedAssignments) {
|
|
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
|
|
}
|
|
})
|
|
}
|
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
|
|
|
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
|
|
const targetIds = ids ?? deletePlaceIds
|
|
if (!targetIds?.length) return
|
|
const state = useTripStore.getState()
|
|
const capturedPlaces = state.places.filter(p => targetIds.includes(p.id))
|
|
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
|
|
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
|
|
)
|
|
try {
|
|
await tripActions.deletePlacesMany(tripId, targetIds)
|
|
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
|
|
if (!ids) setDeletePlaceIds(null)
|
|
updateRouteForDay(selectedDayId)
|
|
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
|
|
if (capturedPlaces.length > 0) {
|
|
pushUndo(t('undo.deletePlaces'), async () => {
|
|
for (const place of capturedPlaces) {
|
|
const newPlace = await tripActions.addPlace(tripId, {
|
|
name: place.name, description: place.description,
|
|
lat: place.lat, lng: place.lng, address: place.address,
|
|
category_id: place.category_id, price: place.price,
|
|
route_geometry: place.route_geometry, route_color: place.route_color,
|
|
})
|
|
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
|
|
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
|
|
|
const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => {
|
|
if (!ids.length) return
|
|
const state = useTripStore.getState()
|
|
// Capture each place's prior category so undo can restore them per group.
|
|
const captured = state.places.filter(p => ids.includes(p.id)).map(p => ({ id: p.id, prev: p.category_id ?? null }))
|
|
try {
|
|
await tripActions.updatePlacesMany(tripId, ids, { category_id: categoryId })
|
|
toast.success(t('places.categoryChanged', { count: ids.length }))
|
|
if (captured.length > 0) {
|
|
pushUndo(t('undo.changeCategory'), async () => {
|
|
// Group the captured ids by their prior category so each set is restored
|
|
// in one call ('null' key = previously uncategorized). Map is shadowed by
|
|
// the lucide icon import in this file, so use a plain object.
|
|
const byPrev: Record<string, number[]> = {}
|
|
for (const { id, prev } of captured) {
|
|
const key = prev === null ? 'null' : String(prev)
|
|
;(byPrev[key] ??= []).push(id)
|
|
}
|
|
for (const [key, group] of Object.entries(byPrev)) {
|
|
await tripActions.updatePlacesMany(tripId, group, { category_id: key === 'null' ? null : Number(key) })
|
|
}
|
|
})
|
|
}
|
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}, [tripId, toast, pushUndo])
|
|
|
|
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
|
|
const target = dayId || selectedDayId
|
|
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
|
const place = places.find(p => p.id === placeId)
|
|
// A place with a start of its own is drawn by it, so it is stored there too, the
|
|
// way a stop moved over from another day is. Without one it goes where it was put.
|
|
const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position
|
|
const plan = viasAfterInsert(target, slot, place)
|
|
try {
|
|
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot)
|
|
toast.success(t('trip.toast.assignedToDay'))
|
|
if (plan) await roadtripVias.reanchor(target, plan)
|
|
updateRouteForDay(target)
|
|
if (assignment?.id) {
|
|
const capturedAssignmentId = assignment.id
|
|
const capturedTarget = target
|
|
pushUndo(t('undo.assignPlace'), async () => {
|
|
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
|
|
})
|
|
}
|
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert])
|
|
|
|
/**
|
|
* Moves a stop from the day list onto another day, at a row of that day or at its
|
|
* end without one.
|
|
*
|
|
* It can land in the middle of a day whose road has been drawn, on the row it was
|
|
* dropped on or among the stops its start falls between. The vias of that day count
|
|
* stops, so every one behind the new stop would shape the leg before the one it was
|
|
* drawn on. They are moved the way a place added to the day moves them. Rejects when
|
|
* a write fails, so the list can say so and leave its undo out.
|
|
*/
|
|
const handleMoveToDay = useCallback(async (assignmentId: number, fromDayId: number, toDayId: number, position?: number) => {
|
|
const place = (storedAssignments[String(fromDayId)] ?? []).find(a => a.id === assignmentId)?.place
|
|
const plan = viasAfterInsert(toDayId, position, place)
|
|
await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, position)
|
|
if (plan) await roadtripVias.reanchor(toDayId, plan)
|
|
}, [tripId, tripActions, storedAssignments, roadtripVias, viasAfterInsert])
|
|
|
|
const handleRemoveAssignment = useCallback(async (dayId: number, assignmentId: number) => {
|
|
const state = useTripStore.getState()
|
|
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
|
|
const capturedPlaceId = capturedAssignment?.place?.id
|
|
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
|
|
// Worked out before the delete, while the day still has the stop the vias
|
|
// were measured against. `after_order_index` is a POSITION, so taking a stop
|
|
// away moves the ground under every via that follows it: the anchors keep
|
|
// their old numbers and the drive silently reverts to the road the traveller
|
|
// steered it off, or bends a leg they never chose. This control is reachable
|
|
// from the place inspector in both modes, and it was the one mutating path
|
|
// that never corrected them.
|
|
const stopsBefore = roadtripStopsOf(dayId)
|
|
const removedAt = stopsBefore.findIndex(a => a.id === assignmentId)
|
|
const plan = removedAt === -1
|
|
? null
|
|
: reanchorAfterRemove(roadtripVias.byDay[dayId] ?? [], removedAt, stopsBefore.length)
|
|
try {
|
|
await tripActions.removeAssignment(tripId, dayId, assignmentId)
|
|
if (plan) await roadtripVias.reanchor(dayId, plan)
|
|
updateRouteForDay(dayId)
|
|
if (capturedPlaceId != null) {
|
|
const capturedDayId = dayId
|
|
const capturedPos = capturedOrderIndex
|
|
pushUndo(t('undo.removeAssignment'), async () => {
|
|
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
|
|
})
|
|
}
|
|
}
|
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf])
|
|
|
|
const handleReorder = useCallback((dayId: number, orderedIds: number[]) => {
|
|
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
|
|
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
|
// The rail counts anchors over routable stops only, so the plan is built in
|
|
// that space. A drag here hands a whole new ordering rather than one move,
|
|
// and any permutation is possible — so the anchors follow the stop they were
|
|
// pinned behind instead of being shifted arithmetically. Without this the
|
|
// day's detours stayed on their old numbers and the drive quietly took a
|
|
// different road, persisted and visible to every collaborator.
|
|
const visible = new Set(orderedIds)
|
|
let nextVisible = 0
|
|
const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id)
|
|
const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id)
|
|
const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id))
|
|
const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter)
|
|
try {
|
|
tripActions.reorderAssignments(tripId, dayId, completeOrder)
|
|
.then(async () => {
|
|
if (plan.vias.length || plan.remove.length) await roadtripVias.reanchor(dayId, plan)
|
|
const capturedDayId = dayId
|
|
const capturedPrevIds = prevIds
|
|
pushUndo(t('undo.reorder'), async () => {
|
|
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
|
})
|
|
})
|
|
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
|
|
updateRouteForDay(dayId)
|
|
}
|
|
catch { toast.error(t('trip.toast.reorderError')) }
|
|
}, [tripId, toast, pushUndo, updateRouteForDay, t, roadtripVias, roadtripStopsOf])
|
|
|
|
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
|
try { await tripActions.updateDayTitle(tripId, dayId, title) }
|
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}, [tripId, toast])
|
|
|
|
const handleReorderDays = useCallback((orderedIds: number[]) => {
|
|
const prevIds = (useTripStore.getState().days || [])
|
|
.slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id)
|
|
tripActions.reorderDays(tripId, orderedIds)
|
|
.then(() => {
|
|
pushUndo(t('dayplan.reorderUndo'), async () => {
|
|
// A day deleted since then drops out of the old order. When the list no
|
|
// longer matches the days there are (one was added), the old order is
|
|
// not one the server could take, so the undo steps aside.
|
|
const live = new Set(useTripStore.getState().days.map(d => d.id))
|
|
const restorable = prevIds.filter(id => live.has(id))
|
|
if (restorable.length !== live.size) return
|
|
await tripActions.reorderDays(tripId, restorable)
|
|
})
|
|
})
|
|
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
|
|
}, [tripId, toast, pushUndo])
|
|
|
|
const handleAddDay = useCallback((position?: number) => {
|
|
tripActions.insertDay(tripId, position)
|
|
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')))
|
|
}, [tripId, toast])
|
|
|
|
// A deleted day can take a stay along, and the selected day's route may have
|
|
// lost its day or its stops.
|
|
const afterDayDeleted = useCallback(() => {
|
|
loadAccommodations()
|
|
updateRouteForDay(useTripStore.getState().selectedDayId)
|
|
}, [loadAccommodations, updateRouteForDay])
|
|
const dayDelete = useDayDelete({
|
|
tripId, trip, days, places: allPlaces, reservations, accommodations: tripAccommodations,
|
|
canEditDays: can('day_edit', trip), t, locale, toast, onDeleted: afterDayDeleted,
|
|
})
|
|
|
|
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
|
try {
|
|
// Imported hotel with a reviewed address but no existing place picked: match
|
|
// an existing place by name, else geocode the address and create one, then link it.
|
|
const acc = (data as Record<string, any>).create_accommodation
|
|
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
|
|
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
|
|
delete acc.venue
|
|
}
|
|
// A hotel's address lives on the linked place. Write an edited address
|
|
// through to it, otherwise the typed value was silently dropped and the
|
|
// old one reappeared on the next open (#1496).
|
|
if (data.type === 'hotel' && acc && typeof acc.address === 'string') {
|
|
const address = acc.address.trim()
|
|
const linkedPlace = acc.place_id ? places.find(p => p.id === Number(acc.place_id)) : undefined
|
|
if (address && linkedPlace && (linkedPlace.address || '') !== address) {
|
|
try { await tripActions.updatePlace(tripId, linkedPlace.id, { address }) }
|
|
catch { /* keep saving the booking; the address still lands in location */ }
|
|
}
|
|
delete acc.address
|
|
}
|
|
if (editingReservation) {
|
|
// Don't force a day here. The old code pinned it to the (often empty)
|
|
// selected day, which dropped the booking out of the Plan; preserving the
|
|
// old day_id instead left it stale when the date changed. Omitting it lets
|
|
// the server derive the day from the booking's date, or keep the current
|
|
// one when there is no date.
|
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
|
toast.success(t('trip.toast.reservationUpdated'))
|
|
setShowReservationModal(false)
|
|
setEditingReservation(null)
|
|
if (data.type === 'hotel') {
|
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
|
}
|
|
return r
|
|
} else {
|
|
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
|
toast.success(t('trip.toast.reservationAdded'))
|
|
setShowReservationModal(false)
|
|
// An imported booking auto-creates a linked cost server-side; the saving client gets
|
|
// no budget:created echo, so refresh the budget items here to surface it without a reload.
|
|
if ((data as Record<string, unknown>).create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
|
|
// Refresh accommodations if hotel was created
|
|
if (data.type === 'hotel') {
|
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
|
}
|
|
return r
|
|
}
|
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}
|
|
|
|
const handleSaveTransport = async (data: Record<string, any> & { title: string }) => {
|
|
try {
|
|
if (editingTransport) {
|
|
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
|
|
toast.success(t('trip.toast.reservationUpdated'))
|
|
setShowTransportModal(false)
|
|
setEditingTransport(null)
|
|
setTransportModalDayId(null)
|
|
return r
|
|
} else {
|
|
const r = await tripActions.addReservation(tripId, data)
|
|
toast.success(t('trip.toast.reservationAdded'))
|
|
setShowTransportModal(false)
|
|
setEditingTransport(null)
|
|
setTransportModalDayId(null)
|
|
// Surface the auto-created linked cost without a reload (no budget:created echo to us).
|
|
if (data.create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
|
|
return r
|
|
}
|
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}
|
|
|
|
const handleDeleteReservation = async (id) => {
|
|
try {
|
|
await tripActions.deleteReservation(tripId, id)
|
|
toast.success(t('trip.toast.deleted'))
|
|
// Refresh accommodations in case a hotel booking was deleted
|
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
|
}
|
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
|
}
|
|
|
|
// ── Review-before-save booking import ───────────────────────────────────────
|
|
// Match an existing trip place by name, else geocode the reviewed address and
|
|
// create one. Returns the place id (or null if even creation failed).
|
|
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
|
|
const name = (venue.name || '').trim()
|
|
const n = name.toLowerCase()
|
|
if (n) {
|
|
const existing = places.find(p => p.name?.trim().toLowerCase() === n)
|
|
?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
|
|
// Only a server-side id may be linked. A negative id is an offline temp id
|
|
// (mutationQueue.nextTempId): the reservation write is online-only, the queue
|
|
// rewrites temp ids in a URL but never inside another entity's body, and
|
|
// day_accommodations.place_id carries a foreign key — so a temp id here is a
|
|
// rolled-back insert and a 500 instead of a saved booking.
|
|
if (existing && existing.id > 0) return existing.id
|
|
}
|
|
// Offline the booking itself cannot be written (reservations are online-only),
|
|
// so minting a place here would only leave an orphan behind on the next flush
|
|
// — and its temp id could never be linked anyway. Link nothing, and skip the
|
|
// geocode round-trip too; the retry online matches this venue by name.
|
|
if (isEffectivelyOffline()) return null
|
|
let lat: number | null = null
|
|
let lng: number | null = null
|
|
let address: string | null = venue.address ?? null
|
|
try {
|
|
const query = venue.address ? `${name} ${venue.address}`.trim() : name
|
|
if (query) {
|
|
const res = await mapsApi.search(query)
|
|
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
|
|
if (hit && hit.lat != null && hit.lng != null) {
|
|
lat = hit.lat; lng = hit.lng
|
|
if (!address && hit.address) address = hit.address
|
|
}
|
|
}
|
|
} catch { /* geocode failure is non-fatal — create the place without coords */ }
|
|
try {
|
|
// Through the store, not placesApi directly: the API answers { place },
|
|
// and reading .id off that wrapper linked nothing — every save of the
|
|
// hotel then minted another orphan place, because the store never
|
|
// learned about the previous one and the name match above could not
|
|
// find it. addPlace unwraps the response and puts the place into
|
|
// `places`, so the next save reuses it.
|
|
const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address })
|
|
return place && place.id > 0 ? place.id : null
|
|
} catch { return null }
|
|
}
|
|
|
|
// Open the right edit modal for a parsed item, pre-filled, in create mode.
|
|
//
|
|
// A type neither form can express belongs to whichever tab the user started from.
|
|
// Handing an unreadable transport document to the booking form is what left them
|
|
// with six chips, none of them a transport, and 'other' as the only honest pick
|
|
// (#2076). A type either form DOES know always wins over the tab — one PDF
|
|
// routinely holds a flight and a hotel.
|
|
const openImportItem = (item: BookingImportPreviewItem) => {
|
|
const draft = parsedItemToDraft(item)
|
|
// Attach the file this item was parsed from so it lands in the booking's Files on save.
|
|
const srcName = item.source?.fileName
|
|
const srcFile = srcName ? importSourceFilesRef.current.find(f => f.name === srcName) : undefined
|
|
if (srcFile) draft._sourceFiles = [srcFile]
|
|
if (isTransportItem(item) || (isUnplaceableItem(item) && importKindRef.current === 'transports')) {
|
|
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
|
|
setEditingTransport(null); setTransportModalDayId(null)
|
|
setTransportPrefill(draft); setShowTransportModal(true)
|
|
} else {
|
|
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
|
|
setEditingReservation(null)
|
|
setReservationPrefill(draft); setShowReservationModal(true)
|
|
}
|
|
}
|
|
|
|
const startImportReview = (
|
|
items: BookingImportPreviewItem[],
|
|
sourceFiles: File[] = [],
|
|
kind: 'transports' | 'bookings' = 'bookings',
|
|
) => {
|
|
if (!items.length) return
|
|
importSourceFilesRef.current = sourceFiles
|
|
importKindRef.current = kind
|
|
importQueueRef.current = items.slice(1)
|
|
setImportReviewActive(true)
|
|
openImportItem(items[0])
|
|
}
|
|
|
|
// Bridge: when a finished background import is sent here for review (the user hit
|
|
// "review" in the background widget, on this or any page), open the per-item flow.
|
|
// Lives in the hook so the page stays a pure wiring container.
|
|
const bgTasks = useBackgroundTasksStore((s) => s.tasks)
|
|
const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss)
|
|
useEffect(() => {
|
|
const task = bgTasks.find(
|
|
(tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed,
|
|
)
|
|
if (task && task.items && task.items.length > 0) {
|
|
// Hand the items (and the source files, to attach to each booking) to the review flow
|
|
// and clear the widget entry — once the user hit "review", the background card is done.
|
|
const items = task.items
|
|
const jobId = task.id
|
|
const inMemory = task.sourceFiles
|
|
const kind = task.kind ?? 'bookings'
|
|
dismissBgTask(jobId)
|
|
// Prefer the in-memory files (immediate path); after a reload they live in IndexedDB.
|
|
void (async () => {
|
|
const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId)
|
|
deleteImportFiles(jobId)
|
|
startImportReview(items, files, kind)
|
|
})()
|
|
}
|
|
}, [bgTasks, tripId, startImportReview, dismissBgTask])
|
|
|
|
// Called when a reviewed item's modal closes (saved or skipped): open the next,
|
|
// or finish the review session and refresh accommodations.
|
|
const advanceImportReview = () => {
|
|
const queue = importQueueRef.current
|
|
if (queue.length > 0) {
|
|
importQueueRef.current = queue.slice(1)
|
|
openImportItem(queue[0])
|
|
return
|
|
}
|
|
importQueueRef.current = []
|
|
setImportReviewActive(false)
|
|
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
|
|
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
|
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
|
// Imported bookings auto-create their linked costs server-side, but the saving client
|
|
// suppresses its own budget:created echo (X-Socket-Id) — so reload the budget items here
|
|
// to surface those expenses without a manual page refresh.
|
|
tripActions.loadBudgetItems?.(tripId)
|
|
}
|
|
|
|
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
|
// The stops the inspector speaks for. A booked night at a day's edge stands on the
|
|
// hotel's place without being a stop of the day, so it is left out: counted, the hotel's
|
|
// own stop lost its stay and its day end to a second match.
|
|
const selectedRoadtripStops = roadtripRoutes.days.flatMap(day => day.stops).filter(stop =>
|
|
!stop.automaticNight && !stop.bookend && (selectedAssignmentId ? stop.assignmentId === selectedAssignmentId : stop.placeId === selectedPlaceId),
|
|
)
|
|
const endDayStop = selectedRoadtripStops.length === 1 ? selectedRoadtripStops[0] : undefined
|
|
const roadtripEndDay = roadtripActive && dailyTimesActive && can('day_edit', trip) && endDayStop && endDayStop.assignmentId > 0
|
|
? { active: roadtripEndsDayAt(endDayStop), onToggle: () => setRoadtripEndDay(endDayStop) }
|
|
: undefined
|
|
const roadtripStay = roadtripActive && selectedPlace
|
|
? inspectorStay(roadtripRoutes.days, endDayStop, selectedPlace, can('place_edit', trip) ? editRoadtripStay : undefined)
|
|
: undefined
|
|
|
|
// Build placeId → order-number map from the selected day's assignments. A service
|
|
// stop is passed over rather than counted: the map draws it without a badge and the
|
|
// rail gives it no number, so a number spent on it left the pin after a fuel stop
|
|
// wearing "3" where the rail said "2".
|
|
const dayOrderMap = useMemo(() => {
|
|
if (!selectedDayId) return {}
|
|
const da = assignments[String(selectedDayId)] || []
|
|
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
|
|
const map = {}
|
|
let counted = 0
|
|
sorted.forEach(a => {
|
|
if (!a.place?.id || isServiceStopType(a.place.stop_type)) return
|
|
counted += 1
|
|
if (!map[a.place.id]) map[a.place.id] = []
|
|
map[a.place.id].push(counted)
|
|
})
|
|
return map
|
|
}, [selectedDayId, assignments])
|
|
|
|
// Places assigned to selected day (with coords) — used for map fitting
|
|
const dayPlaces = useMemo(() => {
|
|
if (!selectedDayId) return []
|
|
const da = assignments[String(selectedDayId)] || []
|
|
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
|
}, [selectedDayId, assignments])
|
|
|
|
const mapTileUrl = useTileUrl(OFM_POSITRON)
|
|
|
|
const fontStyle = { fontFamily: "var(--font-system)" }
|
|
|
|
// Splash screen — show for initial load + a brief moment for photos to start loading
|
|
const [splashDone, setSplashDone] = useState(false)
|
|
useEffect(() => {
|
|
if (!isLoading && trip) {
|
|
const timer = setTimeout(() => setSplashDone(true), 1500)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [isLoading, trip])
|
|
|
|
return {
|
|
tripId, navigate, toast, t, language, locale, settings, placesPhotosEnabled,
|
|
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
|
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
|
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
|
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
|
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
|
overviewShown, toggleOverview, overviewActive, tripOverview,
|
|
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
|
|
followTrack, roadtripViaCounts,
|
|
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
|
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
|
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
|
leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
|
|
startResizeLeft, startResizeRight,
|
|
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
|
|
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
|
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
|
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
|
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
|
|
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
|
stayRelease, setStayRelease, confirmStayRelease,
|
|
setRoadtripStopKind,
|
|
setRoadtripStopFill,
|
|
roadtripEndsDayAt,
|
|
roadtripSettingsLoading: !roadtripPreferencesState.ready && !roadtripPreferencesState.failed,
|
|
saveRoadtripLimit: roadtripPreferencesState.ready && can('day_edit', trip) ? saveRoadtripLimit : undefined,
|
|
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries,
|
|
manualStopTargetFor, openManualRoadtripStop, serviceStopMode, setServiceStopForm,
|
|
refuel, askRefuel, acceptRefuel,
|
|
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint,
|
|
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
|
|
// Addressed by stop rather than by selection: the phone's stage sheet knows which
|
|
// stop it is showing, and going through the place selection there would open the
|
|
// permanently mounted place inspector underneath it.
|
|
setRoadtripEndDay, dailyTimesActive,
|
|
highlightedAlternative, setHighlightedAlternative,
|
|
moveRoadtripStopToDay,
|
|
dropPoiOnRoute,
|
|
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
|
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
|
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
|
|
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
|
bookingForAssignmentId, setBookingForAssignmentId,
|
|
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
|
transportModalDayId, setTransportModalDayId,
|
|
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
|
|
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
|
|
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
|
|
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
|
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
|
|
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
|
isMobile, isTouch,
|
|
expandedDayIds, setExpandedDayIds, mapPlaces,
|
|
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
|
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
|
|
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
|
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
|
...dayDelete,
|
|
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
|
selectedPlace, dayOrderMap, dayPlaces,
|
|
mapTileUrl, fontStyle, splashDone,
|
|
}
|
|
}
|