fix(planner): let a route choice survive the map, and keep new items on their day

Two things the mobile planner forgot.

The day sheet's route toggle only ever lived in component state, and entering
the map — or tapping a day while in it — forced it back on. Turning the route
off therefore did nothing you could see and had reset itself by the time you
looked again. The choice is now remembered per trip; the map still switches it
on by default, but only while the user has never said otherwise.

Adding a place or a booking from inside a day did not connect it to that day.
The place dropped into the unplanned pool, which on mobile is a different screen
— it looks like the save was lost — and the booking opened with no date at all.
Both entry points now carry the day they were opened from, including a
long-press on the mobile map, where the chip rail names a day at all times. The
desktop map has no such context and passes nothing, so its pool behaviour stands.

While here: the import button on the bookings and transports headers only
appeared when kitinerary was installed, though the server also accepts an import
when the LLM parser is configured (#2007).

Closes #2003
Closes #1998
This commit is contained in:
Maurice
2026-08-21 12:47:47 +02:00
committed by Maurice
parent 8b88986ca8
commit da92581a16
8 changed files with 119 additions and 28 deletions
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
import { findTodayDayId } from '../../../components/Planner/today'
import {
ChevronLeft, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck,
ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck,
Plane, Plus, Rows3, Ticket, TrainFront, Trash2, Upload, Wallet,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
@@ -241,7 +241,7 @@ export default function MTripShell({
// Entering the map: draw the current day's route and frame it — the
// BoundsController re-fits to include the polyline once OSRM resolves.
if (next === 'map') {
planner.setRouteShown(true)
planner.autoShowRoute()
if (planner.selectedDayId != null) planner.handleSelectDay(planner.selectedDayId, false)
focusMapOnDay(planner.selectedDayId)
} else {
@@ -284,7 +284,7 @@ export default function MTripShell({
// In map mode a day tap fits to that day's places, draws its route and drops
// the other days' pins, so the day it framed is the day it shows.
if (view === 'map') {
planner.setRouteShown(true)
planner.autoShowRoute()
focusMapOnDay(dayId)
}
}
@@ -335,11 +335,14 @@ export default function MTripShell({
// Inactive chips only — an inline background would otherwise beat
// the active chip's bg-m-act class.
style={active ? undefined : { background: dayTintBackground(tint, 'badge', '--day-tint-chip') }}
className={`flex-1 whitespace-nowrap rounded-full px-3 py-[5px] text-center text-[0.75rem] font-semibold ${
className={`flex flex-1 items-center justify-center gap-[3px] whitespace-nowrap rounded-full px-3 py-[5px] text-center text-[0.75rem] font-semibold ${
active ? 'bg-m-act text-m-actfg shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]' : 'text-m-ink'
}`}
>
{dayChipLabel(day, language, t('planner.dayN', { n: day.day_number ?? idx + 1 }))}
{/* Marks the second tap as "opens the day", the only day-sheet
route that also exists in map view. */}
{active && <ChevronDown size={11} strokeWidth={2.6} aria-hidden="true" className="flex-none opacity-70" />}
</button>
)
})}
@@ -387,7 +390,7 @@ export default function MTripShell({
/>
{planner.bookingImportAvailable && (
<MIconBtn ariaLabel={t('reservations.import.title')} onClick={() => planner.setShowBookingImport(true)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<Upload size={15} strokeWidth={2} />
<Download size={15} strokeWidth={2} />
</MIconBtn>
)}
{planner.airTrailAvailable && (
@@ -407,7 +410,7 @@ export default function MTripShell({
/>
{planner.bookingImportAvailable && (
<MIconBtn ariaLabel={t('reservations.import.title')} onClick={() => planner.setShowBookingImport(true)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<Upload size={15} strokeWidth={2} />
<Download size={15} strokeWidth={2} />
</MIconBtn>
)}
<CompactToggle active={bookingsCompact} onToggle={() => setBookingsCompact(v => !v)} label={t('mobileTrip.compactView')} />
@@ -50,7 +50,10 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
onMarkerClick={planner.handleMarkerClick}
// Tap on empty map = deselect, same contract as desktop.
onMapClick={planner.handleMapClick}
onMapContextMenu={planner.handleMapContextMenu}
// The chip rail names a day at all times on mobile, so a place dropped on
// the map belongs to it — the desktop map has no such context and passes
// nothing, which keeps its pool behaviour (#1998).
onMapContextMenu={e => planner.handleMapContextMenu(e, planner.selectedDayId)}
// No center/zoom: the map frames itself on the trip's places at mount.
tileUrl={planner.mapTileUrl}
fitKey={planner.fitKey}
@@ -62,7 +65,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
// routes this through mapTransportDetail into the day sidebar instead).
onReservationClick={(rid: number) => shell.openSheet('transport', { reservationId: rid })}
pois={poi.pois}
onPoiClick={planner.openAddPlaceFromPoi}
onPoiClick={marker => planner.openAddPlaceFromPoi(marker, planner.selectedDayId)}
onViewportChange={poi.onViewportChange}
onMapReady={setGlMap}
/>
@@ -211,14 +211,18 @@ export function useMPlanTimeline(planner: TripPlanner) {
}
}, [dayAssignments, tripActions, tripId, pushUndo, updateRouteForDay, toast, t])
const moveRow = useCallback((item: MergedItem, direction: 'up' | 'down') => {
/**
* Moves an item to an arbitrary slot. The buttons swap with a neighbour, a
* drag lands anywhere (#1997) — both come through here so the chronology
* guard and the undo entry stay identical either way.
*/
const moveRowTo = useCallback((item: MergedItem, toIndex: number) => {
if (!day) return
const idx = merged.indexOf(item)
const target = direction === 'up' ? idx - 1 : idx + 1
if (idx < 0 || target < 0 || target >= merged.length) return
if (idx < 0 || toIndex < 0 || toIndex >= merged.length || toIndex === idx) return
const newOrder = [...merged]
newOrder[idx] = merged[target]
newOrder[target] = item
newOrder.splice(idx, 1)
newOrder.splice(toIndex, 0, item)
// Same guard as the desktop arrow buttons: a TIMED item may not jump past
// other timed items (untimed items move freely and keep their neighbours).
if (itemHasTime(item, day.id) && breaksChronology(newOrder, day.id, getDisplayTimeForDay)) {
@@ -228,6 +232,11 @@ export function useMPlanTimeline(planner: TripPlanner) {
void applyMergedOrder(day.id, newOrder)
}, [day, merged, applyMergedOrder, toast, t])
const moveRow = useCallback((item: MergedItem, direction: 'up' | 'down') => {
const idx = merged.indexOf(item)
moveRowTo(item, direction === 'up' ? idx - 1 : idx + 1)
}, [merged, moveRowTo])
// ── Place actions ──
const removeAssignment = useCallback((assignment: Assignment) => {
if (!day) return
@@ -240,18 +249,22 @@ export function useMPlanTimeline(planner: TripPlanner) {
}, [planner])
// ── Add bar ──
// Both of these sit inside a day's toolbar, so the day is the context — the
// place gets assigned to it on save and the booking opens pre-dated (#1998).
const addPlace = useCallback(() => {
planner.setEditingPlace(null)
planner.setEditingAssignmentId(null)
planner.setPrefillCoords(null)
planner.setPlaceFormDayId(day?.id ?? null)
planner.setShowPlaceForm(true)
}, [planner])
}, [planner, day])
const addBooking = useCallback(() => {
planner.setEditingReservation(null)
planner.setBookingForAssignmentId(null)
planner.setReservationModalDayId(day?.id ?? null)
planner.setShowReservationModal(true)
}, [planner])
}, [planner, day])
const addTransport = useCallback(() => {
planner.setEditingTransport(null)
@@ -360,6 +373,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
language, timeFormat: settings.time_format,
openTransitKeys, toggleTransit,
moveRow, removeAssignment, editAssignment, editTransport, openTransitJourney,
moveRowTo,
addPlace, addBooking, addTransport,
optimize, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf,
routeModeOptions, setLegMode,
@@ -181,6 +181,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
setEditingPlace(null)
setEditingAssignmentId(null)
setPrefillCoords(null)
planner.setPlaceFormDayId(null)
if (deleteArmed) setDeletePlaceId(null)
}
@@ -125,7 +125,22 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
})
setPendingFiles(pf._sourceFiles ?? [])
} else {
setForm(EMPTY)
// Opened from a day's toolbar: start on that day rather than on a blank
// date the user has to look up again (#1998). A hotel spans to the next
// day, which is what checking in on this day actually means.
const ctxDay = planner.reservationModalDayId != null
? days.find(d => d.id === planner.reservationModalDayId)
: undefined
const ctxDate = ctxDay?.date ? ctxDay.date.slice(0, 10) : ''
const nextDay = ctxDay ? days[days.indexOf(ctxDay) + 1] : undefined
setForm(ctxDate
? {
...EMPTY,
reservation_time: ctxDate,
hotel_start_day: ctxDay!.id,
hotel_end_day: nextDay?.id ?? ctxDay!.id,
}
: EMPTY)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showReservationModal])
@@ -186,6 +201,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const handleClose = () => {
if (importReviewActive) { advanceImportReview(); return }
planner.setReservationModalDayId(null)
setShowReservationModal(false)
setEditingReservation(null)
setBookingForAssignmentId(null)
+54 -7
View File
@@ -200,11 +200,16 @@ export function useTripPlanner() {
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 } | 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)
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); setShowPlaceForm(true)
setEditingPlace(null); setEditingAssignmentId(null); setPlaceFormDayId(null); setShowPlaceForm(true)
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams])
@@ -268,7 +273,30 @@ export function useTripPlanner() {
// 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>').
const [routeShown, setRouteShown] = useState(false)
// 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))
}, [])
const [routeProfile, setRouteProfile] = useState<string>('driving')
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
@@ -288,7 +316,10 @@ export function useTripPlanner() {
}, [trip, places])
useEffect(() => {
healthApi.features().then(f => setBookingImportAvailable(f.bookingImport)).catch(() => {})
// 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
@@ -488,13 +519,14 @@ export function useTripPlanner() {
setSelectedPlaceId(null)
}, [])
const handleMapContextMenu = useCallback(async (e) => {
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)
setShowPlaceForm(true)
try {
const { mapsApi } = await import('../../api/client')
@@ -507,7 +539,7 @@ export function useTripPlanner() {
// 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 }) => {
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) => {
if (!can('place_edit', trip)) return
setPrefillCoords({
lat: poi.lat,
@@ -520,6 +552,7 @@ export function useTripPlanner() {
})
setEditingPlace(null)
setEditingAssignmentId(null)
setPlaceFormDayId(dayId ?? null)
setShowPlaceForm(true)
}, [trip])
@@ -548,6 +581,18 @@ export function useTripPlanner() {
return { id: editingPlace.id }
} else {
const place = await tripActions.addPlace(tripId, data)
// 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 && placeFormDayId != null) {
try {
await tripActions.assignPlaceToDay(tripId, placeFormDayId, place.id)
updateRouteForDay(placeFormDayId)
} 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()
@@ -567,7 +612,7 @@ export function useTripPlanner() {
// exist a moment ago (#1298), the same way the booking modals work.
return place?.id ? { id: place.id } : undefined
}
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
}, [editingPlace, editingAssignmentId, placeFormDayId, tripId, toast, pushUndo, updateRouteForDay])
// 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
@@ -576,6 +621,7 @@ export function useTripPlanner() {
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
setEditingPlace(place)
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
setPlaceFormDayId(null)
setShowPlaceForm(true)
}, [assignments])
@@ -985,6 +1031,7 @@ export function useTripPlanner() {
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportAvailable,
@@ -994,7 +1041,7 @@ export function useTripPlanner() {
transportModalDayId, setTransportModalDayId,
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
routeShown, setRouteShown, autoShowRoute, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
+5
View File
@@ -117,6 +117,10 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
setEditingPlace: vi.fn(),
prefillCoords: null,
setPrefillCoords: vi.fn(),
placeFormDayId: null,
setPlaceFormDayId: vi.fn(),
reservationModalDayId: null,
setReservationModalDayId: vi.fn(),
editingAssignmentId: null,
setEditingAssignmentId: vi.fn(),
showTripForm: false,
@@ -156,6 +160,7 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
routeShown: false,
setRouteShown: vi.fn(),
autoShowRoute: vi.fn(),
routeProfile: 'driving',
setRouteProfile: vi.fn(),
routeVias: [],
@@ -182,7 +182,7 @@ describe('MTripShell', () => {
it('FE-MOB-SHELL-014: the map toggle draws the route and refits the selected day', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
expect(planner.autoShowRoute).toHaveBeenCalled()
expect(planner.handleSelectDay).toHaveBeenCalledWith(11, false)
expect(shellApi.view).toBe('map')
// The timeline unmounts, the map layer stays warm underneath.
@@ -196,13 +196,15 @@ describe('MTripShell', () => {
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }))
expect(shellApi.view).toBe('plan')
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument()
expect(spy(planner, 'setRouteShown')).toHaveBeenCalledTimes(1)
// Entering the map defaults the route on once; leaving it leaves the choice alone.
expect(spy(planner, 'autoShowRoute')).toHaveBeenCalledTimes(1)
expect(spy(planner, 'setRouteShown')).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-016: the map toggle skips the refit while no day is selected', () => {
const { planner } = renderShell({ selectedDayId: null } as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
expect(planner.autoShowRoute).toHaveBeenCalled()
expect(planner.handleSelectDay).not.toHaveBeenCalled()
})
@@ -238,7 +240,7 @@ describe('MTripShell', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.handleSelectDay).toHaveBeenCalledWith(12, true)
expect(planner.setRouteShown).not.toHaveBeenCalled()
expect(planner.autoShowRoute).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-021: tapping the active day opens the day sheet', () => {
@@ -254,7 +256,7 @@ describe('MTripShell', () => {
vi.mocked(planner.setRouteShown).mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(12, false)
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
expect(planner.autoShowRoute).toHaveBeenCalled()
})
// #1962 — the phone lost the desktop sidebar's collapse chevron, so the map showed