diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx
index 624b1e715..0ea0606b2 100644
--- a/client/src/mobile/screens/trip/MTripShell.tsx
+++ b/client/src/mobile/screens/trip/MTripShell.tsx
@@ -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 && }
)
})}
@@ -387,7 +390,7 @@ export default function MTripShell({
/>
{planner.bookingImportAvailable && (
planner.setShowBookingImport(true)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
-
+
)}
{planner.airTrailAvailable && (
@@ -407,7 +410,7 @@ export default function MTripShell({
/>
{planner.bookingImportAvailable && (
planner.setShowBookingImport(true)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
-
+
)}
setBookingsCompact(v => !v)} label={t('mobileTrip.compactView')} />
diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx
index 58aa989f0..91636b0c7 100644
--- a/client/src/mobile/screens/trip/map/MMapArea.tsx
+++ b/client/src/mobile/screens/trip/map/MMapArea.tsx
@@ -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}
/>
diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
index 99587e7c3..ef4b4dc90 100644
--- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
+++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
@@ -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,
diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx
index 3cb27e686..299ed7e28 100644
--- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx
@@ -181,6 +181,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
setEditingPlace(null)
setEditingAssignmentId(null)
setPrefillCoords(null)
+ planner.setPlaceFormDayId(null)
if (deleteArmed) setDeletePlaceId(null)
}
diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx
index 0d87cd84b..de9bba8b5 100644
--- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx
@@ -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)
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index ef59b9519..a3e37be74 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -200,11 +200,16 @@ export function useTripPlanner() {
const [editingPlace, setEditingPlace] = useState(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(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(null)
+ const [reservationModalDayId, setReservationModalDayId] = useState(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:/').
- 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(() => {
+ 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('driving')
const [fitKey, setFitKey] = useState(0)
const initialFitTripId = useRef(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,
diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts
index b6000d3f0..638887f4b 100644
--- a/client/tests/helpers/mobileTrip.ts
+++ b/client/tests/helpers/mobileTrip.ts
@@ -117,6 +117,10 @@ export function buildPlanner(overrides: Partial = {}): 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
routeShown: false,
setRouteShown: vi.fn(),
+ autoShowRoute: vi.fn(),
routeProfile: 'driving',
setRouteProfile: vi.fn(),
routeVias: [],
diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx
index 271987acd..f2d3a454c 100644
--- a/client/tests/unit/mobile/trip/MTripShell.test.tsx
+++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx
@@ -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)
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