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 && (