feat(map): per-trip booking-route visibility with account default and bulk toggle

Lets a user see a booking's route on the map without manually
toggling it per item, two ways:

- The account setting from the previous commit sets the default for
  any trip that's never had its routes touched before.
- A new bulk "show all / hide all" button in the day-plan toolbar
  flips a trip explicitly between the two connectionsVisibility modes,
  independent of the per-item toggle (which still edits whichever
  mode's id list is active, in both directions, including while the
  account default is on).

useTripPlanner.ts resolves a trip's effective visible-connection ids
from connectionsVisibility.ts + the account setting + the trip's
routable reservations, and exposes it through the same
visibleConnections/toggleConnection contract MapView, MapViewGL and
DayPlanSidebar already had, plus allConnectionsShown/
toggleAllConnections for the new bulk control. MapView/MapViewGL
consume it via the shared reservationRoutes util instead of each
carrying their own copy of the filter.

The bulk toggle's tooltip gets its own map.showAllConnections/
hideAllConnections i18n keys (all 22 locales) distinct from the
per-item toggle's text, and matches the per-item toggle's active
(solid blue) styling rather than the toolbar's generic hover tint.

Manually verified end-to-end in a running dev server: the account
default seeding an untouched trip, the bulk toggle flipping a trip
between all-shown/all-hidden, and a single per-leg override while the
trip is in all-shown mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Blomme
2026-07-07 14:26:35 +02:00
committed by Maurice
parent 4c13b4e40f
commit fcc9c24fe4
30 changed files with 211 additions and 29 deletions
+29 -1
View File
@@ -4,7 +4,7 @@ import { render, screen } from '../../../tests/helpers/render'
import { fireEvent, waitFor } from '@testing-library/react' import { fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store' import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories' import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService' import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({ const mapMock = vi.hoisted(() => ({
@@ -15,6 +15,7 @@ const mapMock = vi.hoisted(() => ({
on: vi.fn(), on: vi.fn(),
off: vi.fn(), off: vi.fn(),
panBy: vi.fn(), panBy: vi.fn(),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
})) }))
vi.mock('react-leaflet', () => ({ vi.mock('react-leaflet', () => ({
@@ -42,6 +43,7 @@ vi.mock('react-leaflet', () => ({
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />, Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />, CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />, Circle: () => <div data-testid="circle" />,
Tooltip: ({ children }: any) => <>{children}</>,
useMap: () => mapMock, useMap: () => mapMock,
useMapEvents: () => ({}), useMapEvents: () => ({}),
})) }))
@@ -349,4 +351,30 @@ describe('MapView', () => {
expect(zoom).toBe(2) expect(zoom).toBe(2)
}) })
}) })
it('FE-COMP-MAPVIEW-023: a routable reservation not in visibleConnectionIds draws no route', () => {
const reservation = buildReservation({
id: 43,
type: 'flight',
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
} as any)
render(<MapView reservations={[reservation]} visibleConnectionIds={[]} />)
expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
})
it('FE-COMP-MAPVIEW-024: a routable reservation in visibleConnectionIds draws its route', () => {
const reservation = buildReservation({
id: 42,
type: 'flight',
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
} as any)
render(<MapView reservations={[reservation]} visibleConnectionIds={[42]} />)
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
}) })
+4 -6
View File
@@ -11,6 +11,7 @@ import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import ReservationOverlay from './ReservationOverlay' import ReservationOverlay from './ReservationOverlay'
import { PluginMapMarkers } from './MapPluginMarkers' import { PluginMapMarkers } from './MapPluginMarkers'
import { useTransportRoutes } from '../../hooks/useTransportRoutes' import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import type { Reservation } from '../../types' import type { Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
@@ -478,12 +479,9 @@ export const MapView = memo(function MapView({
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip> <Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
</Marker> </Marker>
)), [pois, onPoiClick]) )), [pois, onPoiClick])
const visibleReservations = useMemo(() => { const visibleReservations = useMemo(() => (
const set = new Set(visibleConnectionIds || []) visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes })
// Transit journeys ride the route toggle — they are part of the computed ), [reservations, visibleConnectionIds, showTransitRoutes])
// day route, so hiding the route hides them too (#1065).
return reservations.filter((r: Reservation) => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
}, [reservations, visibleConnectionIds, showTransitRoutes])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails). // Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations) const transportRoutes = useTransportRoutes(visibleReservations)
// Dynamic padding: account for sidebars + bottom inspector + day detail panel // Dynamic padding: account for sidebars + bottom inspector + day detail panel
+4 -6
View File
@@ -12,6 +12,7 @@ import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings,
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox' import { ReservationMapboxOverlay } from './reservationsMapbox'
import { useTransportRoutes } from '../../hooks/useTransportRoutes' import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
@@ -928,12 +929,9 @@ export function MapViewGL({
// `visibleConnectionIds` is driven by the per-reservation toggle in // `visibleConnectionIds` is driven by the per-reservation toggle in
// DayPlanSidebar — nothing is rendered until the user enables a // DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour. // booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => { const visibleReservations = useMemo(() => (
const set = new Set(visibleConnectionIds || []) visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes })
// Transit journeys ride the route toggle — they are part of the computed ), [reservations, visibleConnectionIds, showTransitRoutes])
// day route, so hiding the route hides them too (#1065).
return reservations.filter(r => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
}, [reservations, visibleConnectionIds, showTransitRoutes])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails). // Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations) const transportRoutes = useTransportRoutes(visibleReservations)
@@ -392,6 +392,53 @@ describe('DayPlanSidebar', () => {
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument() expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
}) })
it('FE-PLANNER-DAYPLAN-106: the toolbar button shows "show all" and calls onToggleAllConnections when not all routes are shown', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const res1 = {
...buildReservation({ id: 401, type: 'flight' }),
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
}
const onToggleAllConnections = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], reservations: [res1 as any],
visibleConnectionIds: [], onToggleConnection: vi.fn(),
allConnectionsShown: false, onToggleAllConnections,
})} />)
await user.click(screen.getByLabelText('Show all booking routes'))
expect(onToggleAllConnections).toHaveBeenCalledTimes(1)
})
it('FE-PLANNER-DAYPLAN-107: the toolbar button reads "hide" once allConnectionsShown is true, and calls onToggleAllConnections on click', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const res1 = {
...buildReservation({ id: 401, type: 'flight' }),
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
}
const onToggleAllConnections = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], reservations: [res1 as any],
visibleConnectionIds: [401], onToggleConnection: vi.fn(),
allConnectionsShown: true, onToggleAllConnections,
})} />)
await user.click(screen.getByLabelText('Hide all booking routes'))
expect(onToggleAllConnections).toHaveBeenCalledTimes(1)
})
it('FE-PLANNER-DAYPLAN-108: the toolbar button is absent when the trip has no routable reservation', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [], onToggleAllConnections: vi.fn() })} />)
expect(screen.queryByLabelText('Show all booking routes')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Hide all booking routes')).not.toBeInTheDocument()
})
// ── Day info button ───────────────────────────────────────────────────── // ── Day info button ─────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-018: clicking day header calls onDayDetail', async () => { it('FE-PLANNER-DAYPLAN-018: clicking day header calls onDayDetail', async () => {
@@ -69,6 +69,8 @@ interface DayPlanSidebarProps {
reservations?: Reservation[] reservations?: Reservation[]
visibleConnectionIds?: number[] visibleConnectionIds?: number[]
onToggleConnection?: (reservationId: number) => void onToggleConnection?: (reservationId: number) => void
allConnectionsShown?: boolean
onToggleAllConnections?: () => void
externalTransportDetail?: Reservation | null externalTransportDetail?: Reservation | null
onExternalTransportDetailHandled?: () => void onExternalTransportDetailHandled?: () => void
onAddReservation: (dayId: number) => void onAddReservation: (dayId: number) => void
@@ -122,6 +124,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
reservations = [], reservations = [],
visibleConnectionIds = [], visibleConnectionIds = [],
onToggleConnection, onToggleConnection,
allConnectionsShown = false,
onToggleAllConnections,
externalTransportDetail, externalTransportDetail,
onExternalTransportDetailHandled, onExternalTransportDetailHandled,
onAddReservation, onAddReservation,
@@ -1014,6 +1018,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
reservations, reservations,
visibleConnectionIds, visibleConnectionIds,
onToggleConnection, onToggleConnection,
allConnectionsShown,
onToggleAllConnections,
externalTransportDetail, externalTransportDetail,
onExternalTransportDetailHandled, onExternalTransportDetailHandled,
onAddReservation, onAddReservation,
@@ -1183,6 +1189,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
reservations, reservations,
visibleConnectionIds, visibleConnectionIds,
onToggleConnection, onToggleConnection,
allConnectionsShown,
onToggleAllConnections,
externalTransportDetail, externalTransportDetail,
onExternalTransportDetailHandled, onExternalTransportDetailHandled,
onAddReservation, onAddReservation,
@@ -1323,6 +1331,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
categories={categories} categories={categories}
assignments={assignments} assignments={assignments}
reservations={reservations} reservations={reservations}
allConnectionsShown={allConnectionsShown}
onToggleAllConnections={onToggleAllConnections}
dayNotes={dayNotes} dayNotes={dayNotes}
t={t} t={t}
locale={locale} locale={locale}
@@ -1,10 +1,11 @@
import React, { useState, useRef } from 'react' import React, { useState, useRef } from 'react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus } from 'lucide-react' import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, Route as RouteIcon } from 'lucide-react'
import { downloadTripPDF } from '../PDF/TripPDF' import { downloadTripPDF } from '../PDF/TripPDF'
import { DayReorderPopup } from './DayReorderPopup' import { DayReorderPopup } from './DayReorderPopup'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { IcsSubscribeModal } from './IcsSubscribeModal' import { IcsSubscribeModal } from './IcsSubscribeModal'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
interface DayPlanSidebarToolbarProps { interface DayPlanSidebarToolbarProps {
@@ -15,6 +16,8 @@ interface DayPlanSidebarToolbarProps {
categories: Category[] categories: Category[]
assignments: AssignmentsMap assignments: AssignmentsMap
reservations: Reservation[] reservations: Reservation[]
allConnectionsShown?: boolean
onToggleAllConnections?: () => void
dayNotes: Record<string, DayNote[]> dayNotes: Record<string, DayNote[]>
t: (key: string, params?: Record<string, any>) => string t: (key: string, params?: Record<string, any>) => string
locale: string locale: string
@@ -37,6 +40,7 @@ interface DayPlanSidebarToolbarProps {
export function DayPlanSidebarToolbar({ export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
allConnectionsShown = false, onToggleAllConnections,
t, locale, toast, pdfHover, setPdfHover, setIcsHover, t, locale, toast, pdfHover, setPdfHover, setIcsHover,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, onReorderDays, onAddDay, canEditDays, onReorderDays, onAddDay,
@@ -295,6 +299,32 @@ export function DayPlanSidebarToolbar({
/> />
</div> </div>
)} )}
{onToggleAllConnections && reservations.some(isRoutableReservation) && (() => {
const label = t(allConnectionsShown ? 'map.hideAllConnections' : 'map.showAllConnections')
return (
<Tooltip label={label} placement="bottom">
<button
type="button"
onClick={onToggleAllConnections}
aria-label={label}
aria-pressed={allConnectionsShown}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 8,
border: allConnectionsShown ? 'none' : '1px solid var(--border-primary)',
background: allConnectionsShown ? '#3b82f6' : 'none',
color: allConnectionsShown ? '#fff' : 'var(--text-primary)',
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
}}
onMouseEnter={e => { if (!allConnectionsShown) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!allConnectionsShown) e.currentTarget.style.background = 'transparent' }}
>
<RouteIcon size={14} strokeWidth={2} />
</button>
</Tooltip>
)
})()}
</div> </div>
</div> </div>
) )
+5 -3
View File
@@ -180,7 +180,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
// Page = wiring container: the entire planner state machine (store, tabs, // Page = wiring container: the entire planner state machine (store, tabs,
// selection, CRUD handlers with undo, map filters, splash) lives in the hook. // selection, CRUD handlers with undo, map filters, splash) lives in the hook.
const { const {
tripId, navigate, toast, t, language, settings, placesPhotosEnabled, tripId, navigate, toast, t, language, placesPhotosEnabled,
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files, trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles, selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo, pushUndo, undo, canUndo, lastActionLabel, handleUndo,
@@ -204,7 +204,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey, routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail, visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch, isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces, expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
@@ -419,6 +419,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
reservations={reservations} reservations={reservations}
visibleConnectionIds={visibleConnections} visibleConnectionIds={visibleConnections}
onToggleConnection={toggleConnection} onToggleConnection={toggleConnection}
allConnectionsShown={allConnectionsShown}
onToggleAllConnections={toggleAllConnections}
externalTransportDetail={mapTransportDetail} externalTransportDetail={mapTransportDetail}
onExternalTransportDetailHandled={() => setMapTransportDetail(null)} onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
@@ -646,7 +648,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}> <div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} 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 }} showRouteToolsWhenExpanded isMobile /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} 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 }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> : <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
} }
</div> </div>
+37 -12
View File
@@ -27,6 +27,12 @@ import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types' import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { resolvePoolAssignmentId } from './tripPlannerModel' import { resolvePoolAssignmentId } from './tripPlannerModel'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import {
parseStoredConnections, resolveEffectiveConnections, resolveVisibleConnectionIds,
toggleConnectionId, toggleAllConnections as flipAllConnectionsMode,
type StoredConnections,
} from '../../utils/connectionsVisibility'
/** /**
* Trip planner page logic — the big one. Owns the trip store wiring, addon * Trip planner page logic — the big one. Owns the trip store wiring, addon
@@ -249,20 +255,39 @@ export function useTripPlanner() {
}, []) }, [])
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => { // Per-trip route-visibility preference — null means "never touched", which
if (typeof window === 'undefined' || !connectionsStorageKey) return [] // falls back to the account-wide map_always_show_routes default (see
try { // connectionsVisibility.ts). That fallback is purely computed, never
const stored = window.localStorage.getItem(connectionsStorageKey) // written, so flipping the account setting later doesn't silently override
return stored ? JSON.parse(stored) as number[] : [] // a trip you've already made an explicit choice on.
} catch { return [] } const [storedConnections, setStoredConnections] = useState<StoredConnections | null>(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return null
return parseStoredConnections(window.localStorage.getItem(connectionsStorageKey))
}) })
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return if (typeof window === 'undefined' || !connectionsStorageKey || !storedConnections) return
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(visibleConnections)) window.localStorage.setItem(connectionsStorageKey, JSON.stringify(storedConnections))
}, [connectionsStorageKey, visibleConnections]) }, [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) => { const toggleConnection = useCallback((id: number) => {
setVisibleConnections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) setStoredConnections(prev => toggleConnectionId(prev, alwaysShowRoutesDefault, id))
}, []) }, [alwaysShowRoutesDefault])
const toggleAllConnections = useCallback(() => {
setStoredConnections(prev => flipAllConnectionsMode(prev, alwaysShowRoutesDefault))
}, [alwaysShowRoutesDefault])
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null) const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768) const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
@@ -922,7 +947,7 @@ export function useTripPlanner() {
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey, routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail, visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch, isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces, expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'متاحف وثقافة', 'poi.cat.museums': 'متاحف وثقافة',
'poi.cat.nature': 'طبيعة وحدائق', 'poi.cat.nature': 'طبيعة وحدائق',
'poi.cat.activities': 'أنشطة', 'poi.cat.activities': 'أنشطة',
'map.showAllConnections': 'إظهار جميع مسارات الحجوزات',
'map.hideAllConnections': 'إخفاء جميع مسارات الحجوزات',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Museus e cultura', 'poi.cat.museums': 'Museus e cultura',
'poi.cat.nature': 'Natureza e parques', 'poi.cat.nature': 'Natureza e parques',
'poi.cat.activities': 'Atividades', 'poi.cat.activities': 'Atividades',
'map.showAllConnections': 'Mostrar todas as rotas de reservas',
'map.hideAllConnections': 'Ocultar todas as rotas de reservas',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Muzea a kultura', 'poi.cat.museums': 'Muzea a kultura',
'poi.cat.nature': 'Příroda a parky', 'poi.cat.nature': 'Příroda a parky',
'poi.cat.activities': 'Aktivity', 'poi.cat.activities': 'Aktivity',
'map.showAllConnections': 'Zobrazit všechny trasy rezervací',
'map.hideAllConnections': 'Skrýt všechny trasy rezervací',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Museen & Kultur', 'poi.cat.museums': 'Museen & Kultur',
'poi.cat.nature': 'Natur & Parks', 'poi.cat.nature': 'Natur & Parks',
'poi.cat.activities': 'Aktivitäten', 'poi.cat.activities': 'Aktivitäten',
'map.showAllConnections': 'Alle Buchungsrouten anzeigen',
'map.hideAllConnections': 'Alle Buchungsrouten ausblenden',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Museums & culture', 'poi.cat.museums': 'Museums & culture',
'poi.cat.nature': 'Nature & parks', 'poi.cat.nature': 'Nature & parks',
'poi.cat.activities': 'Activities', 'poi.cat.activities': 'Activities',
'map.showAllConnections': 'Show all booking routes',
'map.hideAllConnections': 'Hide all booking routes',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Museos y cultura', 'poi.cat.museums': 'Museos y cultura',
'poi.cat.nature': 'Naturaleza y parques', 'poi.cat.nature': 'Naturaleza y parques',
'poi.cat.activities': 'Actividades', 'poi.cat.activities': 'Actividades',
'map.showAllConnections': 'Mostrar todas las rutas de reservas',
'map.hideAllConnections': 'Ocultar todas las rutas de reservas',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Musées & culture', 'poi.cat.museums': 'Musées & culture',
'poi.cat.nature': 'Nature & parcs', 'poi.cat.nature': 'Nature & parcs',
'poi.cat.activities': 'Activités', 'poi.cat.activities': 'Activités',
'map.showAllConnections': 'Afficher tous les itinéraires',
'map.hideAllConnections': 'Masquer tous les itinéraires',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Μουσεία & πολιτισμός', 'poi.cat.museums': 'Μουσεία & πολιτισμός',
'poi.cat.nature': 'Φύση & πάρκα', 'poi.cat.nature': 'Φύση & πάρκα',
'poi.cat.activities': 'Δραστηριότητες', 'poi.cat.activities': 'Δραστηριότητες',
'map.showAllConnections': 'Εμφάνιση όλων των διαδρομών κρατήσεων',
'map.hideAllConnections': 'Απόκρυψη όλων των διαδρομών κρατήσεων',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Múzeumok és kultúra', 'poi.cat.museums': 'Múzeumok és kultúra',
'poi.cat.nature': 'Természet és parkok', 'poi.cat.nature': 'Természet és parkok',
'poi.cat.activities': 'Programok', 'poi.cat.activities': 'Programok',
'map.showAllConnections': 'Összes foglalási útvonal megjelenítése',
'map.hideAllConnections': 'Összes foglalási útvonal elrejtése',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Museum & budaya', 'poi.cat.museums': 'Museum & budaya',
'poi.cat.nature': 'Alam & taman', 'poi.cat.nature': 'Alam & taman',
'poi.cat.activities': 'Aktivitas', 'poi.cat.activities': 'Aktivitas',
'map.showAllConnections': 'Tampilkan semua rute pemesanan',
'map.hideAllConnections': 'Sembunyikan semua rute pemesanan',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Musei e cultura', 'poi.cat.museums': 'Musei e cultura',
'poi.cat.nature': 'Natura e parchi', 'poi.cat.nature': 'Natura e parchi',
'poi.cat.activities': 'Attività', 'poi.cat.activities': 'Attività',
'map.showAllConnections': 'Mostra tutti i percorsi prenotati',
'map.hideAllConnections': 'Nascondi tutti i percorsi prenotati',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': '美術館・文化施設', 'poi.cat.museums': '美術館・文化施設',
'poi.cat.nature': '自然・公園', 'poi.cat.nature': '自然・公園',
'poi.cat.activities': 'アクティビティ', 'poi.cat.activities': 'アクティビティ',
'map.showAllConnections': 'すべての予約ルートを表示',
'map.hideAllConnections': 'すべての予約ルートを非表示',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': '박물관 & 문화', 'poi.cat.museums': '박물관 & 문화',
'poi.cat.nature': '자연 & 공원', 'poi.cat.nature': '자연 & 공원',
'poi.cat.activities': '액티비티', 'poi.cat.activities': '액티비티',
'map.showAllConnections': '모든 예약 경로 표시',
'map.hideAllConnections': '모든 예약 경로 숨기기',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Musea & cultuur', 'poi.cat.museums': 'Musea & cultuur',
'poi.cat.nature': 'Natuur & parken', 'poi.cat.nature': 'Natuur & parken',
'poi.cat.activities': 'Activiteiten', 'poi.cat.activities': 'Activiteiten',
'map.showAllConnections': 'Alle boekingsroutes tonen',
'map.hideAllConnections': 'Alle boekingsroutes verbergen',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Muzea i kultura', 'poi.cat.museums': 'Muzea i kultura',
'poi.cat.nature': 'Przyroda i parki', 'poi.cat.nature': 'Przyroda i parki',
'poi.cat.activities': 'Aktywności', 'poi.cat.activities': 'Aktywności',
'map.showAllConnections': 'Pokaż wszystkie trasy rezerwacji',
'map.hideAllConnections': 'Ukryj wszystkie trasy rezerwacji',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Музеи и культура', 'poi.cat.museums': 'Музеи и культура',
'poi.cat.nature': 'Природа и парки', 'poi.cat.nature': 'Природа и парки',
'poi.cat.activities': 'Развлечения', 'poi.cat.activities': 'Развлечения',
'map.showAllConnections': 'Показать все маршруты бронирований',
'map.hideAllConnections': 'Скрыть все маршруты бронирований',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Museer och kultur', 'poi.cat.museums': 'Museer och kultur',
'poi.cat.nature': 'Natur och parker', 'poi.cat.nature': 'Natur och parker',
'poi.cat.activities': 'Aktiviteter', 'poi.cat.activities': 'Aktiviteter',
'map.showAllConnections': 'Visa alla bokningsvägar',
'map.hideAllConnections': 'Dölj alla bokningsvägar',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Müzeler ve kültür', 'poi.cat.museums': 'Müzeler ve kültür',
'poi.cat.nature': 'Doğa ve parklar', 'poi.cat.nature': 'Doğa ve parklar',
'poi.cat.activities': 'Aktiviteler', 'poi.cat.activities': 'Aktiviteler',
'map.showAllConnections': 'Tüm rezervasyon rotalarını göster',
'map.hideAllConnections': 'Tüm rezervasyon rotalarını gizle',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Музеї та культура', 'poi.cat.museums': 'Музеї та культура',
'poi.cat.nature': 'Природа та парки', 'poi.cat.nature': 'Природа та парки',
'poi.cat.activities': 'Активності', 'poi.cat.activities': 'Активності',
'map.showAllConnections': 'Показати всі маршрути бронювань',
'map.hideAllConnections': 'Приховати всі маршрути бронювань',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': 'Bảo tàng & văn hóa', 'poi.cat.museums': 'Bảo tàng & văn hóa',
'poi.cat.nature': 'Thiên nhiên & công viên', 'poi.cat.nature': 'Thiên nhiên & công viên',
'poi.cat.activities': 'Các hoạt động', 'poi.cat.activities': 'Các hoạt động',
'map.showAllConnections': 'Hiển thị tất cả lộ trình đặt chỗ',
'map.hideAllConnections': 'Ẩn tất cả lộ trình đặt chỗ',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': '博物館與文化', 'poi.cat.museums': '博物館與文化',
'poi.cat.nature': '自然與公園', 'poi.cat.nature': '自然與公園',
'poi.cat.activities': '活動', 'poi.cat.activities': '活動',
'map.showAllConnections': '顯示所有預訂路線',
'map.hideAllConnections': '隱藏所有預訂路線',
}; };
export default map; export default map;
+2
View File
@@ -13,5 +13,7 @@ const map: TranslationStrings = {
'poi.cat.museums': '博物馆与文化', 'poi.cat.museums': '博物馆与文化',
'poi.cat.nature': '自然与公园', 'poi.cat.nature': '自然与公园',
'poi.cat.activities': '活动', 'poi.cat.activities': '活动',
'map.showAllConnections': '显示所有预订路线',
'map.hideAllConnections': '隐藏所有预订路线',
}; };
export default map; export default map;