- {merged.map((item: any, idx: number) => {
+ {merged.map((item: any) => {
if (item.type === 'transport') {
const r = item.data
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
- const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
+ const time = splitReservationDateTime(r.reservation_time).time ?? ''
let sub = ''
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
@@ -273,8 +277,9 @@ export default function SharedTripPage() {
{(reservations || []).map((r: any) => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
- const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
- const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
+ const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
+ const time = rTime ?? ''
+ const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
return (
diff --git a/client/src/pages/TripPlannerPage.test.tsx b/client/src/pages/TripPlannerPage.test.tsx
index 78096249..3ea43503 100644
--- a/client/src/pages/TripPlannerPage.test.tsx
+++ b/client/src/pages/TripPlannerPage.test.tsx
@@ -1474,6 +1474,56 @@ describe('TripPlannerPage', () => {
});
});
+ describe('FE-PAGE-PLANNER-051: Mobile Plan sidebar stays mounted after onPlaceClick (issue #932)', () => {
+ it('does not unmount the mobile Plan portal when a place is tapped, preserving scroll position', async () => {
+ vi.useFakeTimers();
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
+
+ const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
+ const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
+ seedTripStore({ id: 42 });
+ seedStore(useTripStore, {
+ places: [place],
+ assignments: { '99': [assignment] },
+ } as any);
+
+ renderPlannerPage(42);
+ act(() => { vi.runAllTimers(); });
+ vi.useRealTimers();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
+ });
+
+ // Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049).
+ const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
+ b => b.textContent === 'Plan' && !b.getAttribute('title'),
+ );
+ expect(mobilePlanBtn).toBeTruthy();
+ await act(async () => { fireEvent.click(mobilePlanBtn!); });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
+ });
+
+ // The mock factory overwrites capturedDayPlanSidebarProps on each mount,
+ // so current holds the mobile portal instance's props.
+ const mobileOnPlaceClick = capturedDayPlanSidebarProps.current.onPlaceClick;
+ expect(typeof mobileOnPlaceClick).toBe('function');
+
+ await act(async () => {
+ mobileOnPlaceClick(place.id, assignment.id);
+ });
+
+ // Invariant: portal must NOT unmount — both instances persist.
+ // Pre-fix: collapses to 1 (setMobileSidebarOpen(null) destroyed scroll container).
+ // Post-fix: stays at 2, browser preserves scrollTop on the living DOM node.
+ expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
+
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
+ });
+ });
+
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
vi.useFakeTimers();
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index f4d3dde8..c19ed55c 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -272,6 +272,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [fitKey, setFitKey] = useState(0)
const initialFitTripId = useRef(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
+ const mobilePlanScrollTopRef = useRef(0)
+ const mobilePlacesScrollTopRef = useRef(0)
const [deletePlaceId, setDeletePlaceId] = useState(null)
const [deletePlaceIds, setDeletePlaceIds] = useState(null)
@@ -666,15 +668,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleSaveTransport = async (data) => {
try {
if (editingTransport) {
- await tripActions.updateReservation(tripId, editingTransport.id, data)
+ const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated'))
+ setShowTransportModal(false)
+ setEditingTransport(null)
+ setTransportModalDayId(null)
+ return r
} else {
- await tripActions.addReservation(tripId, data)
+ const r = await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded'))
+ setShowTransportModal(false)
+ setEditingTransport(null)
+ setTransportModalDayId(null)
+ return r
}
- setShowTransportModal(false)
- setEditingTransport(null)
- setTransportModalDayId(null)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
@@ -996,6 +1003,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
collapsed={dayDetailCollapsed}
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
+ mobile={isMobile}
/>
)
})()}
@@ -1109,8 +1117,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left'
- ?
{ handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
- : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
+ ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
+ : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
@@ -1167,7 +1175,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{activeTab === 'dateien' && (
-