From 8065af28ef4d475275c86c5bf449bb815e1f2106 Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Fri, 14 Aug 2026 10:24:14 +0300 Subject: [PATCH] feat(planner): open places and day routes in CoMaps Add CoMaps, the offline OpenStreetMap navigator, alongside the existing map integrations. A geolocated place gains a CoMaps entry in its navigation menu, and a day's route toolbar gains a button that hands the day's bookended stops to CoMaps in that day's travel mode. CoMaps' route link carries only a start and a destination, so a two-stop day goes as a route and a longer one as named pins rather than silently losing the stops in between. Links use the https form so a device without CoMaps installed lands on comaps.at instead of a dead scheme. The hotel-bookend logic behind the Google Maps export was duplicated between desktop and mobile; both now share dayExportStops so the two links cannot disagree about which hotel legs are real. Addresses discussion #1904 --- .../components/Map/RouteCalculator.test.ts | 41 ++++++++++++ client/src/components/Map/RouteCalculator.ts | 36 ++++++++++ .../Planner/DayPlanSidebar.test.tsx | 45 +++++++++++-- .../src/components/Planner/DayPlanSidebar.tsx | 64 ++++++++++++------ .../components/Planner/placeCoMaps.test.ts | 32 +++++++++ client/src/components/Planner/placeCoMaps.ts | 22 ++++++ .../Planner/placeNavigation.test.ts | 16 +++-- .../src/components/Planner/placeNavigation.ts | 8 ++- .../mobile/screens/trip/lib/dayRoute.test.ts | 67 +++++++++++++++++++ .../src/mobile/screens/trip/lib/dayRoute.ts | 64 +++++++++++++----- .../screens/trip/plan/MPlanTimeline.tsx | 3 +- .../screens/trip/plan/useMPlanTimeline.ts | 13 +++- .../mobile/screens/trip/sheets/MDaySheet.tsx | 24 ++++++- shared/src/i18n/ar/mobileTrip.ts | 1 + shared/src/i18n/ar/planner.ts | 1 + shared/src/i18n/br/mobileTrip.ts | 1 + shared/src/i18n/br/planner.ts | 1 + shared/src/i18n/ca/mobileTrip.ts | 1 + shared/src/i18n/ca/planner.ts | 1 + shared/src/i18n/cs/mobileTrip.ts | 1 + shared/src/i18n/cs/planner.ts | 1 + shared/src/i18n/de/mobileTrip.ts | 1 + shared/src/i18n/de/planner.ts | 1 + shared/src/i18n/en/mobileTrip.ts | 1 + shared/src/i18n/en/planner.ts | 1 + shared/src/i18n/es/mobileTrip.ts | 1 + shared/src/i18n/es/planner.ts | 1 + shared/src/i18n/fr/mobileTrip.ts | 1 + shared/src/i18n/fr/planner.ts | 1 + shared/src/i18n/gr/mobileTrip.ts | 1 + shared/src/i18n/gr/planner.ts | 1 + shared/src/i18n/hu/mobileTrip.ts | 1 + shared/src/i18n/hu/planner.ts | 1 + shared/src/i18n/id/mobileTrip.ts | 1 + shared/src/i18n/id/planner.ts | 1 + shared/src/i18n/it/mobileTrip.ts | 1 + shared/src/i18n/it/planner.ts | 1 + shared/src/i18n/ja/mobileTrip.ts | 1 + shared/src/i18n/ja/planner.ts | 1 + shared/src/i18n/ko/mobileTrip.ts | 1 + shared/src/i18n/ko/planner.ts | 1 + shared/src/i18n/nl/mobileTrip.ts | 1 + shared/src/i18n/nl/planner.ts | 1 + shared/src/i18n/pl/mobileTrip.ts | 1 + shared/src/i18n/pl/planner.ts | 1 + shared/src/i18n/ru/mobileTrip.ts | 1 + shared/src/i18n/ru/planner.ts | 1 + shared/src/i18n/sv/mobileTrip.ts | 1 + shared/src/i18n/sv/planner.ts | 1 + shared/src/i18n/tr/mobileTrip.ts | 1 + shared/src/i18n/tr/planner.ts | 1 + shared/src/i18n/uk/mobileTrip.ts | 1 + shared/src/i18n/uk/planner.ts | 1 + shared/src/i18n/vi/mobileTrip.ts | 1 + shared/src/i18n/vi/planner.ts | 1 + shared/src/i18n/zh-TW/mobileTrip.ts | 1 + shared/src/i18n/zh-TW/planner.ts | 1 + shared/src/i18n/zh/mobileTrip.ts | 1 + shared/src/i18n/zh/planner.ts | 1 + 59 files changed, 429 insertions(+), 52 deletions(-) create mode 100644 client/src/components/Planner/placeCoMaps.test.ts create mode 100644 client/src/components/Planner/placeCoMaps.ts create mode 100644 client/src/mobile/screens/trip/lib/dayRoute.test.ts diff --git a/client/src/components/Map/RouteCalculator.test.ts b/client/src/components/Map/RouteCalculator.test.ts index 62bc45597..e1fb2ddbb 100644 --- a/client/src/components/Map/RouteCalculator.test.ts +++ b/client/src/components/Map/RouteCalculator.test.ts @@ -9,6 +9,7 @@ import { calculateSegments, optimizeRoute, generateGoogleMapsUrl, + generateCoMapsUrl, parsePluginProfile, withHotelBookends, } from './RouteCalculator' @@ -572,3 +573,43 @@ describe('calculateRouteWithLegs cache eviction', () => { expect(hits).toBe(1) }) }) + +// ── generateCoMapsUrl ───────────────────────────────────────────────────────── + +describe('generateCoMapsUrl', () => { + const eiffel = { lat: 48.8584, lng: 2.2945, name: 'Eiffel Tower' } + const louvre = { lat: 48.8606, lng: 2.3376, name: 'Louvre' } + const notre = { lat: 48.8530, lng: 2.3499, name: 'Notre-Dame' } + + it('FE-COMP-ROUTECALCULATOR-016: no stops, no link', () => { + expect(generateCoMapsUrl([])).toBeNull() + }) + + it('FE-COMP-ROUTECALCULATOR-017: two stops build a real route, mode included', () => { + expect(generateCoMapsUrl([eiffel, louvre], 'walking')).toBe( + 'https://comaps.at/route?sll=48.8584,2.2945&saddr=Eiffel%20Tower' + + '&dll=48.8606,2.3376&daddr=Louvre&type=pedestrian', + ) + }) + + it('FE-COMP-ROUTECALCULATOR-018: TREK profiles map onto CoMaps travel modes', () => { + expect(generateCoMapsUrl([eiffel, louvre], 'driving')).toContain('type=vehicle') + expect(generateCoMapsUrl([eiffel, louvre], 'cycling')).toContain('type=bicycle') + // A plugin router has no CoMaps equivalent, so it falls back rather than + // sending a mode CoMaps would reject. + expect(generateCoMapsUrl([eiffel, louvre], 'plugin:ev/fast')).toContain('type=vehicle') + }) + + it('FE-COMP-ROUTECALCULATOR-019: three stops go as pins, because a route link would drop the middle', () => { + const url = generateCoMapsUrl([eiffel, louvre, notre])! + expect(url).toBe( + 'https://comaps.at/map?v=1&ll=48.8584,2.2945&n=Eiffel%20Tower' + + '&ll=48.8606,2.3376&n=Louvre&ll=48.853,2.3499&n=Notre-Dame', + ) + }) + + it('FE-COMP-ROUTECALCULATOR-020: a single stop is a pin, and a nameless one is labelled by position', () => { + expect(generateCoMapsUrl([{ lat: 48.85, lng: 2.35 }])) + .toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35') + }) +}) diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 94c76391f..7bbfeb43b 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -116,6 +116,42 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null { return `https://www.google.com/maps/dir/${stops}` } +/** A stop that can carry its name into a deep link that has somewhere to put one. */ +export type NamedWaypoint = Waypoint & { name?: string | null } + +/** TREK's route profiles in CoMaps' vocabulary; a plugin profile has no equivalent and drives. */ +function coMapsRouteType(profile: RouteProfileKey): string { + if (profile === 'walking') return 'pedestrian' + if (profile === 'cycling') return 'bicycle' + return 'vehicle' +} + +/** + * Open a day's stops in CoMaps for offline navigation (#1904). + * + * CoMaps has two links and they trade against each other. `route` builds real + * turn-by-turn in the given travel mode but takes a start and a destination and + * nothing between them; `map` takes any number of named pins but routes nothing. + * So a two-stop day goes as a route — everything it has fits, mode included — + * and a longer one goes as pins, because handing over the whole day and letting + * CoMaps route leg by leg beats quietly dropping the middle of someone's plan. + * A day that needs the full itinerary as one navigable track has the GPX export. + * + * https rather than `cm://` for the same reason as `getCoMapsUrlForPlace`. + */ +export function generateCoMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null { + const valid = places.filter((p) => p.lat != null && p.lng != null) + if (valid.length === 0) return null + const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || `${p.lat},${p.lng}`) + if (valid.length === 2) { + const [from, to] = valid + return `https://comaps.at/route?sll=${from.lat},${from.lng}&saddr=${label(from)}` + + `&dll=${to.lat},${to.lng}&daddr=${label(to)}&type=${coMapsRouteType(profile)}` + } + const pins = valid.map((p) => `ll=${p.lat},${p.lng}&n=${label(p)}`).join('&') + return `https://comaps.at/map?v=1&${pins}` +} + // Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine. function sqDist(a: Waypoint, b: Waypoint): number { return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2 diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index ff5002f8a..eb0d2d992 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -14,7 +14,7 @@ import { buildUser, buildTrip, buildDay, buildPlace, buildCategory, buildAssignment, buildDayNote, buildReservation, } from '../../../tests/helpers/factories' import type { Accommodation, Reservation } from '../../types' -import { calculateRouteWithLegs, generateGoogleMapsUrl } from '../Map/RouteCalculator' +import { calculateRouteWithLegs, generateCoMapsUrl, generateGoogleMapsUrl } from '../Map/RouteCalculator' import DayPlanSidebar from './DayPlanSidebar' import { makeMarkerDraggable } from '../Map/markerDrag' @@ -60,6 +60,7 @@ vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(un vi.mock('../Map/RouteCalculator', () => ({ calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }), generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'), + generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'), optimizeRoute: vi.fn().mockImplementation((places) => places), // One leg per waypoint gap; the connector between two stops reads distanceText. calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) => Promise.resolve({ @@ -190,6 +191,7 @@ beforeEach(() => { })), })) vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...') + vi.mocked(generateCoMapsUrl).mockReturnValue('https://comaps.at/...') sessionStorage.clear() localStorage.clear() // Cost totals fetch FX rates for their base currency; keep the suite hermetic. @@ -3354,15 +3356,48 @@ describe('DayPlanSidebar', () => { render() await user.click(screen.getByRole('button', { name: 'Open in Google Maps' })) expect(vi.mocked(generateGoogleMapsUrl)).toHaveBeenCalledWith([ - { lat: 48.85, lng: 2.35 }, - { lat: 48.86, lng: 2.34 }, - { lat: 48.87, lng: 2.33 }, - { lat: 48.85, lng: 2.35 }, + { lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' }, + { lat: 48.86, lng: 2.34, name: 'Louvre' }, + { lat: 48.87, lng: 2.33, name: 'Orsay' }, + { lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' }, ]) expect(openSpy).toHaveBeenCalledWith('https://maps.google.com/...', '_blank', 'noopener,noreferrer') openSpy.mockRestore() }) + it('FE-PLANNER-DAYPLAN-168b: the CoMaps export hands over the same stops in the day travel mode', async () => { + const user = userEvent.setup() + const { generateCoMapsUrl } = await import('../Map/RouteCalculator') + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2', default_transport_mode: 'walking' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const accommodations: Accommodation[] = [{ + id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12, + place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia', + }] + const assignments = { + '11': [ + buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }), + buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }), + ], + } + render() + await user.click(screen.getByRole('button', { name: 'Open in CoMaps' })) + // Same bookended stop list as the Google export — one source, so the two + // cannot drift — plus the day's own mode, which CoMaps can actually route in. + expect(vi.mocked(generateCoMapsUrl)).toHaveBeenCalledWith([ + { lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' }, + { lat: 48.86, lng: 2.34, name: 'Louvre' }, + { lat: 48.87, lng: 2.33, name: 'Orsay' }, + { lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' }, + ], 'walking') + expect(openSpy).toHaveBeenCalledWith('https://comaps.at/...', '_blank', 'noopener,noreferrer') + openSpy.mockRestore() + }) + it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => { const user = userEvent.setup() const { daysApi } = await import('../../api/client') diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index f76ff9451..b3460fad9 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -4,9 +4,9 @@ declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react' import { avatarSrc } from '../../utils/avatarSrc' -import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react' import { assignmentsApi, reservationsApi, daysApi } from '../../api/client' -import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator' +import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator' import PlaceAvatar from '../shared/PlaceAvatar' import ConfirmDialog from '../shared/ConfirmDialog' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' @@ -1382,6 +1382,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP (transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng) ) const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer + /** + * The day's located stops in planned order, bookended by the accommodation + * the same way the drawn map route is (routeBookends is null when "optimize + * from accommodation" is off), so hotels aren't dropped from an exported + * route (#1372) — but only when the leg is real: no hotel prepended before an + * early check-in-day stop, none appended after a post-check-out stop (#1465). + * Names ride along for the deep links that can label a pin with one. + */ + const dayExportStops = (): NamedWaypoint[] => { + const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null) + const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name })) + const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined + const lastAssignment = dayStops[dayStops.length - 1] + const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined + const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first) + const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last) + const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null + ? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null + const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null + ? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null + return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])] + } // Is this day's inline route currently on? Mobile toggles it per day (its // own expandedRouteDayIds entry); desktop uses the global Route toggle on // the selected day (#1374). @@ -2468,24 +2490,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {/* Open the day's stops as a route in Google Maps (planned order). #1255 */} + {/* The same day, handed to CoMaps for offline navigation (#1904). The + day's own travel mode rides along, so the route it builds walks + when the plan walks. */} + )} + {routable && ( + + )} {canEditDays && dayAssignments.length >= 3 && (