From b773faff70980ec27fb9a72722d7fa6297974018 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 00:24:45 +0200 Subject: [PATCH] feat(roadtrip): share trip preferences and improve service stops --- client/src/components/Map/ClusteredPois.tsx | 2 +- client/src/components/Map/MapViewGL.test.tsx | 2 + client/src/components/Map/MapViewGL.tsx | 2 +- client/src/components/Map/poiClusters.test.ts | 12 ++ client/src/components/Map/poiClusters.ts | 4 +- .../src/components/Planner/DayPlanSidebar.tsx | 11 +- .../Roadtrip/RoadtripLimitsCard.test.tsx | 13 +++ .../Roadtrip/RoadtripLimitsCard.tsx | 20 +++- .../components/Roadtrip/RoadtripSidebar.tsx | 7 +- .../Roadtrip/StopFillPicker.test.tsx | 4 + .../components/Roadtrip/StopFillPicker.tsx | 3 +- .../Roadtrip/useRefuelSearch.test.ts | 21 +++- .../components/Roadtrip/useRefuelSearch.ts | 53 ++++----- .../Roadtrip/useRoadtripRoutes.test.ts | 4 + .../components/Roadtrip/useRoadtripRoutes.ts | 19 +-- .../Roadtrip/useVehicleRange.test.ts | 6 +- .../components/Roadtrip/useVehicleRange.ts | 33 ++---- client/src/db/offlineDb.ts | 5 + .../hooks/useRoadtripPreferencesSync.test.ts | 35 +++--- .../src/hooks/useRoadtripPreferencesSync.ts | 19 ++- client/src/hooks/useRoadtripSettings.ts | 42 +++++++ client/src/hooks/useRouteCalculation.ts | 9 +- client/src/pages/TripPlannerPage.tsx | 8 +- .../useTripPlanner.roadtrip.test.tsx | 45 +++++++- .../src/pages/tripPlanner/useTripPlanner.ts | 70 ++++++++--- .../src/repo/roadtripPreferencesRepo.test.ts | 44 +++++++ client/src/repo/roadtripPreferencesRepo.ts | 55 +++++++++ client/src/store/roadtripPreferencesStore.ts | 14 +++ client/src/sync/mutationQueue.ts | 6 +- .../src/sync/tripSyncManager.prepare.test.ts | 2 + client/src/sync/tripSyncManager.ts | 4 + .../tests/unit/sync/tripSyncManager.test.ts | 2 + server/src/db/migrations.ts | 6 + .../nest/roadtrip/roadtrip-plan.service.ts | 2 +- .../nest/roadtrip/roadtrip-planning.mcp.ts | 2 +- .../roadtrip-preferences.controller.ts | 30 +++++ .../nest/roadtrip/roadtrip-preferences.mcp.ts | 30 +++-- .../roadtrip/roadtrip-preferences.service.ts | 43 +++++-- server/src/nest/roadtrip/roadtrip.module.ts | 54 ++++++--- server/src/nest/trips/trips.service.ts | 1 + server/tests/e2e/roadtrip.e2e.test.ts | 109 ++++++++++++++---- .../db/roadtrip-preferences-migration.test.ts | 38 ++++++ .../tests/unit/nest/roadtrip-planning.test.ts | 68 ++++++----- shared/src/i18n/ar/roadtrip.ts | 15 ++- shared/src/i18n/br/roadtrip.ts | 27 +++-- shared/src/i18n/ca/roadtrip.ts | 30 +++-- shared/src/i18n/cs/roadtrip.ts | 21 +++- shared/src/i18n/de/roadtrip.ts | 27 +++-- shared/src/i18n/en/roadtrip.ts | 21 +++- shared/src/i18n/es/roadtrip.ts | 27 +++-- shared/src/i18n/fr/roadtrip.ts | 24 ++-- shared/src/i18n/gr/roadtrip.ts | 27 +++-- shared/src/i18n/hu/roadtrip.ts | 21 +++- shared/src/i18n/id/roadtrip.ts | 27 +++-- shared/src/i18n/it/roadtrip.ts | 27 +++-- shared/src/i18n/ja/roadtrip.ts | 18 ++- shared/src/i18n/ko/roadtrip.ts | 18 ++- shared/src/i18n/nl/roadtrip.ts | 27 +++-- shared/src/i18n/pl/roadtrip.ts | 24 ++-- shared/src/i18n/ru/roadtrip.ts | 27 +++-- shared/src/i18n/sv/roadtrip.ts | 24 ++-- shared/src/i18n/tr/roadtrip.ts | 24 ++-- shared/src/i18n/uk/roadtrip.ts | 27 +++-- shared/src/i18n/vi/roadtrip.ts | 24 ++-- shared/src/i18n/zh-TW/roadtrip.ts | 8 +- shared/src/i18n/zh/roadtrip.ts | 8 +- shared/src/realtime/events.schema.spec.ts | 6 +- shared/src/realtime/events.schema.ts | 3 +- shared/src/roadtrip/planning.schema.ts | 2 +- shared/src/roadtrip/preferences.schema.ts | 6 + wiki/MCP-Addon-Tools.md | 6 +- wiki/Trip-Planner-Overview.md | 6 +- 72 files changed, 1136 insertions(+), 375 deletions(-) create mode 100644 client/src/hooks/useRoadtripSettings.ts create mode 100644 client/src/repo/roadtripPreferencesRepo.test.ts create mode 100644 client/src/repo/roadtripPreferencesRepo.ts create mode 100644 client/src/store/roadtripPreferencesStore.ts create mode 100644 server/src/nest/roadtrip/roadtrip-preferences.controller.ts create mode 100644 server/tests/unit/db/roadtrip-preferences-migration.test.ts diff --git a/client/src/components/Map/ClusteredPois.tsx b/client/src/components/Map/ClusteredPois.tsx index 5cd630914..986de81bf 100644 --- a/client/src/components/Map/ClusteredPois.tsx +++ b/client/src/components/Map/ClusteredPois.tsx @@ -18,7 +18,7 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: { map.on('zoomend', update) return () => { map.off('zoomend', update) } }, [map]) - const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng])) : [], [pois, enabled, map, zoom]) + const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom]) const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]])) if (!enabled) return <>{children} return <>{groups.map(group => { diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 0b789ca30..0769fbf9b 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -1391,6 +1391,7 @@ describe('MapViewGL', () => { it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => { loadOnAttach() + vi.mocked(glMap.getZoom).mockReturnValue(8) const poi: Poi = { osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station', poi_type: 'charging_station', address: null, website: null, phone: null, @@ -1411,6 +1412,7 @@ describe('MapViewGL', () => { const list = calls[calls.length - 1][0] act(() => { list.querySelectorAll('button')[1].click() }) expect(onPoiClick).toHaveBeenCalledWith(other) + vi.mocked(glMap.getZoom).mockReturnValue(10) }) it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => { diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 8aa2aac05..6394bbc3c 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -1840,7 +1840,7 @@ export function MapViewGL({ poiMarkersRef.current = [] poiCleanupRef.current.forEach(off => off()) poiCleanupRef.current = [] - const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat])) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng })) + const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat]), map.getZoom()) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng })) for (const group of groups) { if (group.pois.length > 1) { const el = document.createElement('button') diff --git a/client/src/components/Map/poiClusters.test.ts b/client/src/components/Map/poiClusters.test.ts index a54b64787..c23f6a2eb 100644 --- a/client/src/components/Map/poiClusters.test.ts +++ b/client/src/components/Map/poiClusters.test.ts @@ -10,6 +10,12 @@ const station = (id: string, lng: number): Poi => ({ const project = (poi: Poi) => ({ x: poi.lng, y: poi.lat }) describe('station clusters', () => { + it('only clusters in the distant overview, even for coincident stations', () => { + const pois = [station('a', 1), station('b', 1)] + expect(clusterPois(pois, project, 8)).toHaveLength(1) + expect(clusterPois(pois, project, 9)).toHaveLength(2) + expect(clusterPois(pois, project, 14)).toHaveLength(2) + }) it('groups across grid edges, counts unique stations and preserves the originals', () => { const a = station('a', 47), b = station('b', 49), c = station('c', 150) const groups = clusterPois([c, b, a, a], project) @@ -41,3 +47,9 @@ describe('station clusters', () => { expect(select).toHaveBeenCalledWith(poi) }) }) + +it('combines nearby small groups into one overview cluster', () => { + const pois = [station('a', 0), station('b', 3), station('c', 20), station('d', 23), station('e', 40), station('f', 43)] + expect(clusterPois(pois, project, 8).map(group => group.pois.length)).toEqual([6]) + expect(clusterPois(pois, project, 9)).toHaveLength(6) +}) diff --git a/client/src/components/Map/poiClusters.ts b/client/src/components/Map/poiClusters.ts index e0f5f1f6f..03dbaeb01 100644 --- a/client/src/components/Map/poiClusters.ts +++ b/client/src/components/Map/poiClusters.ts @@ -1,13 +1,15 @@ import type { Poi } from './poiCategories' export const POI_CLUSTER_RADIUS = 48 +export const POI_CLUSTER_MAX_ZOOM = 8 export const POI_CLUSTER_DETAIL_ZOOM = 18 export interface PoiCluster { pois: Poi[]; lat: number; lng: number } -export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }): PoiCluster[] { +export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }, zoom = 0): PoiCluster[] { const groups: (PoiCluster & { x: number; y: number })[] = [] const cells = new Map() const unique = new Map(pois.map(poi => [poi.osm_id, poi])) + if (zoom > POI_CLUSTER_MAX_ZOOM) return [...unique.values()].map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng })) for (const poi of [...unique.values()].sort((a, b) => a.osm_id.localeCompare(b.osm_id))) { const point = project(poi) const cx = Math.floor(point.x / POI_CLUSTER_RADIUS), cy = Math.floor(point.y / POI_CLUSTER_RADIUS) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index bd4c793b8..2017b720d 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1,3 +1,5 @@ +import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' +import { isServiceStopType } from '../Roadtrip/roadtripModel' /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string } declare global { interface Window { __dragData: DragDataPayload | null } } @@ -180,6 +182,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const { t, language, locale } = useTranslation() const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false) const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) @@ -409,7 +412,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { } const getDayAssignments = (dayId) => - (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) + (assignments[String(dayId)] || []).filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type)).slice().sort((a, b) => a.order_index - b.order_index) // Compute initial day_plan_position for a transport based on time const computeTransportPosition = (r, da) => { @@ -485,7 +488,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { return map // getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure // eslint-disable-next-line react-hooks/exhaustive-deps - }, [days, assignments, dayNotes, reservations, transportPosVersion]) + }, [days, assignments, dayNotes, reservations, transportPosVersion, mirrorServiceStops]) // Days whose inline route legs should be computed & shown. Desktop: the selected // day while the Route toggle is on. Mobile: each expanded day the user tapped @@ -710,7 +713,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // Unified reorder: assigns positions to ALL item types based on new visual order const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => { // Capture previous place order for undo - const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id) + const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) // …and, per booking, the fields this call is about to overwrite, so a failed write // can put the visible order back instead of leaving a phantom one behind the error // toast. Restoring the whole array instead would also drop what a collaborator's @@ -957,7 +960,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const da = getDayAssignments(dayId) if (da.length < 3) return - const prevIds = da.map(a => a.id) + const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) // Separate fixed (stay at their index) and movable assignments. A place is // fixed if it's locked OR has a set time — timed places are anchored by their diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx index 0ef283d90..a7ffd1c7e 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx @@ -276,3 +276,16 @@ describe('RoadtripLimitsCard', () => { }) }) }) + +vi.mock('../../hooks/useRoadtripSettings', () => ({ + useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)), +})) + +it('shares service stops with Days by default and lets the trip turn it off', () => { + const onSave = vi.fn() + open(onSave) + const toggle = screen.getByRole('button', { name: 'Show in Days too' }) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(toggle) + expect(onSave).toHaveBeenCalledWith('roadtrip_service_stops_in_days', false) +}) diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index 7f67bfc39..ad13f964a 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -1,3 +1,4 @@ +import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import React, { useState } from 'react' import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check, @@ -250,7 +251,8 @@ const SPEC_ROWS: Record Promise /** * Persists one setting. Absent leaves the dialog read-only. @@ -261,9 +263,10 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries }: { onSave?: (key: string, value: number | string | boolean) => void }): React.ReactElement { const { t } = useTranslation() - const settings = useSettingsStore(s => s.settings) + const settings = useRoadtripSettings(s => s) + const unit = useSettingsStore(s => s.settings.distance_unit) const [open, setOpen] = useState(false) - const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric' + const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric' const imperial = distanceUnit === 'imperial' const legMinutes = settings.roadtrip_leg_minutes @@ -380,6 +383,8 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries }: {