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
This commit is contained in:
Konstantinos Thermos
2026-08-14 10:24:14 +03:00
parent 813b9886b3
commit 8065af28ef
59 changed files with 429 additions and 52 deletions
@@ -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')
})
})
@@ -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
@@ -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(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
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(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
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')
@@ -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 */}
<button
onClick={() => {
// Bookend the Google Maps route with the day's accommodation the
// same way the drawn map route does (routeBookends is null when
// "optimize from accommodation" is off), so hotels aren't dropped
// from the 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).
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! }))
const firstStop = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
const lastAssignment = dayStops[dayStops.length - 1]
const lastStop = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, firstStop)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, lastStop)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
@@ -2504,6 +2509,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
</button>
{/* 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. */}
<button
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
title={t('planner.openCoMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<Compass size={14} strokeWidth={2} />
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 8, border: 'none',
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import type { Place } from '../../types'
// FE-PLANNER-COMAPS-001 to FE-PLANNER-COMAPS-004
function place(overrides: Partial<Place> = {}): Place {
return { name: 'Stephansdom', lat: 48.2038, lng: 16.3616, ...overrides } as unknown as Place
}
describe('getCoMapsUrlForPlace', () => {
it('FE-PLANNER-COMAPS-001: drops a named pin at the place', () => {
expect(getCoMapsUrlForPlace(place()))
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616&n=Stephansdom')
})
it('FE-PLANNER-COMAPS-002: links over https, so an install-less device lands somewhere', () => {
// The cm:// scheme opens the app but dead-ends in a browser without it;
// comaps.at is both the app link and the install page.
expect(getCoMapsUrlForPlace(place())).toMatch(/^https:\/\/comaps\.at\//)
})
it('FE-PLANNER-COMAPS-003: a nameless place still gets its pin', () => {
expect(getCoMapsUrlForPlace(place({ name: '' })))
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616')
})
it('FE-PLANNER-COMAPS-004: without coordinates there is nothing to pin', () => {
expect(getCoMapsUrlForPlace(place({ lat: null, lng: null }))).toBeNull()
expect(getCoMapsUrlForPlace(null)).toBeNull()
})
})
@@ -0,0 +1,22 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
/**
* Open a place in CoMaps, the privacy-focused offline OpenStreetMap navigator.
* Requested in discussion #1904.
*
* The https form rather than the `cm://` scheme CoMaps also answers to: both open
* the app when it is installed, but only this one has somewhere to land when it is
* not — comaps.at, which is the install page. A PWA cannot ask whether an Android
* app exists, so the fallback has to be the link itself.
*
* Coordinates are required: CoMaps places the pin from `ll` and uses `n` only to
* label it, so a name on its own has nothing to attach to.
*/
export function getCoMapsUrlForPlace(place: PlaceLike | null | undefined): string | null {
if (!place || place.lat == null || place.lng == null) return null
const name = place.name?.trim()
const n = name ? `&n=${encodeURIComponent(name)}` : ''
return `https://comaps.at/map?v=1&ll=${place.lat},${place.lng}${n}`
}
@@ -1,4 +1,3 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
import type { Place } from '../../types'
@@ -31,7 +30,7 @@ afterEach(() => { vi.restoreAllMocks() })
describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
expect(targets[0].label).toBe('Google Maps')
})
@@ -59,7 +58,7 @@ describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm'])
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm', 'comaps'])
} finally { restore() }
})
@@ -67,7 +66,7 @@ describe('getNavigationTargets', () => {
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
try {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
// q next to ll labels the pin rather than searching blindly.
expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616')
} finally { restore() }
@@ -88,6 +87,13 @@ describe('getNavigationTargets', () => {
expect(targets.map(t => t.id)).toEqual(['google', 'osm'])
})
it('FE-PLANNER-NAV-007b: CoMaps needs the position, so a place without one loses it', () => {
// CoMaps drops a pin from `ll` and only labels it with `n` — a name alone
// has nothing to attach to, the same reason Waze and Apple Maps drop out.
const targets = getNavigationTargets(place({ lat: null, lng: null, name: 'Stephansdom' }))
expect(targets.map(t => t.id)).not.toContain('comaps')
})
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
expect(getNavigationTargets(null)).toEqual([])
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
@@ -95,7 +101,7 @@ describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => {
const targets = getNavigationTargets(place({ name: '' }))
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
})
})
@@ -1,10 +1,11 @@
import type { AssignmentPlace, Place } from '../../types'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm'
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps'
export interface NavigationTarget {
id: NavigationAppId
@@ -86,6 +87,11 @@ export function getNavigationTargets(
const osmUrl = getOpenStreetMapUrlForPlace(place)
if (osmUrl) targets.push({ id: 'osm', label: 'OpenStreetMap', url: osmUrl })
// Last, beside the OSM entry it shares a map source with: CoMaps is the offline
// end of this list, the one that still works with no signal.
const coMapsUrl = getCoMapsUrlForPlace(place)
if (coMapsUrl) targets.push({ id: 'comaps', label: 'CoMaps', url: coMapsUrl })
return targets
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl } from './dayRoute'
import type { Accommodation, Assignment, Day } from '../../../../types'
// FE-MOBILE-DAYROUTE-001 to FE-MOBILE-DAYROUTE-005
const days = [
{ id: 10, day_number: 1 },
{ id: 20, day_number: 2 },
{ id: 30, day_number: 3 },
] as unknown as Day[]
const hotel = {
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
start_day_id: 10, end_day_id: 30,
} as unknown as Accommodation
const stop = (id: number, name: string, lat: number, lng: number): Assignment =>
({ id, day_id: 20, order_index: id, place: { id, name, lat, lng } }) as unknown as Assignment
const louvre = stop(1, 'Louvre', 48.86, 2.34)
const orsay = stop(2, 'Orsay', 48.87, 2.33)
describe('dayExportStops', () => {
it('FE-MOBILE-DAYROUTE-001: carries the stops in planned order, hotel-bookended and named', () => {
expect(dayExportStops(days[1], days, [louvre, orsay], [hotel], true)).toEqual([
{ 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' },
])
})
it('FE-MOBILE-DAYROUTE-002: drops the bookends when accommodation optimization is off', () => {
const stops = dayExportStops(days[1], days, [louvre, orsay], [hotel], false)
expect(stops.map(s => s.name)).toEqual(['Louvre', 'Orsay'])
})
it('FE-MOBILE-DAYROUTE-003: a stop without coordinates cannot be exported', () => {
const nowhere = { id: 3, day_id: 20, order_index: 3, place: { id: 3, name: 'TBD' } } as unknown as Assignment
const stops = dayExportStops(days[1], days, [louvre, nowhere], [], false)
expect(stops.map(s => s.name)).toEqual(['Louvre'])
})
})
describe('day map links', () => {
it('FE-MOBILE-DAYROUTE-004: both exports read the same stop list', () => {
// One source for the bookend rules, so the two links can never disagree
// about which hotel legs are real.
const google = dayGoogleMapsUrl(days[1], days, [louvre, orsay], [hotel], true)!
const coMaps = dayCoMapsUrl(days[1], days, [louvre, orsay], [hotel], true, 'walking')!
for (const ll of ['48.85,2.35', '48.86,2.34', '48.87,2.33']) {
expect(google).toContain(ll)
expect(coMaps).toContain(ll)
}
})
it('FE-MOBILE-DAYROUTE-005: the CoMaps link travels in the mode it was given', () => {
// Four bookended stops is more than a CoMaps route link can hold, so it
// becomes pins — which is also why no mode appears here.
expect(dayCoMapsUrl(days[1], days, [louvre, orsay], [hotel], true, 'walking'))
.toMatch(/^https:\/\/comaps\.at\/map\?v=1&/)
// Two stops fit a route, and then the mode does apply.
expect(dayCoMapsUrl(days[1], days, [louvre, orsay], [], false, 'walking'))
.toContain('type=pedestrian')
})
})
+48 -16
View File
@@ -1,4 +1,7 @@
import { generateGoogleMapsUrl, optimizeRoute } from '../../../../components/Map/RouteCalculator'
import {
generateCoMapsUrl, generateGoogleMapsUrl, optimizeRoute,
type NamedWaypoint, type RouteProfileKey,
} from '../../../../components/Map/RouteCalculator'
import {
getAccommodationAnchors, getDayBookendHotels, shouldDrawEveningLeg, shouldDrawMorningLeg,
} from '../../../../utils/dayOrder'
@@ -54,9 +57,33 @@ export function optimizeDayOrder(
}
/**
* Google-Maps directions URL over the day's located stops, bookended by the
* morning/evening accommodation exactly like the drawn route.
* The day's located stops in planned order, bookended by the morning/evening
* accommodation exactly like the drawn route. Names ride along for the deep
* links that can label a pin with one.
*/
export function dayExportStops(
day: Day,
days: Day[],
dayAssignments: Assignment[],
accommodations: Accommodation[],
bookendFromAccommodation: boolean,
): NamedWaypoint[] {
const located = dayAssignments.filter(a => a.place?.lat != null && a.place?.lng != null)
const stops = located.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
const bookends = bookendFromAccommodation ? getDayBookendHotels(day, days, accommodations) : null
const firstStop = located[0] ? { isPlace: true, time: located[0].place?.place_time ?? null } : undefined
const last = located[located.length - 1]
const lastStop = last ? { isPlace: true, time: last.place?.place_time ?? null } : undefined
const morning = bookends && shouldDrawMorningLeg(bookends, day, firstStop)
&& bookends.morning?.place_lat != null && bookends.morning?.place_lng != null
? { lat: bookends.morning.place_lat, lng: bookends.morning.place_lng, name: bookends.morning.place_name } : null
const evening = bookends && shouldDrawEveningLeg(bookends, day, lastStop)
&& bookends.evening?.place_lat != null && bookends.evening?.place_lng != null
? { lat: bookends.evening.place_lat, lng: bookends.evening.place_lng, name: bookends.evening.place_name } : null
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
}
/** Google-Maps directions URL over the day's bookended stops. */
export function dayGoogleMapsUrl(
day: Day,
days: Day[],
@@ -64,17 +91,22 @@ export function dayGoogleMapsUrl(
accommodations: Accommodation[],
bookendFromAccommodation: boolean,
): string | null {
const located = dayAssignments.filter(a => a.place?.lat != null && a.place?.lng != null)
const stops = located.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! }))
const bookends = bookendFromAccommodation ? getDayBookendHotels(day, days, accommodations) : null
const firstStop = located[0] ? { isPlace: true, time: located[0].place?.place_time ?? null } : undefined
const last = located[located.length - 1]
const lastStop = last ? { isPlace: true, time: last.place?.place_time ?? null } : undefined
const morning = bookends && shouldDrawMorningLeg(bookends, day, firstStop)
&& bookends.morning?.place_lat != null && bookends.morning?.place_lng != null
? { lat: bookends.morning.place_lat, lng: bookends.morning.place_lng } : null
const evening = bookends && shouldDrawEveningLeg(bookends, day, lastStop)
&& bookends.evening?.place_lat != null && bookends.evening?.place_lng != null
? { lat: bookends.evening.place_lat, lng: bookends.evening.place_lng } : null
return generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]) || null
return generateGoogleMapsUrl(
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation),
) || null
}
/** The same stops handed to CoMaps for offline navigation, in the day's travel mode (#1904). */
export function dayCoMapsUrl(
day: Day,
days: Day[],
dayAssignments: Assignment[],
accommodations: Accommodation[],
bookendFromAccommodation: boolean,
profile: RouteProfileKey,
): string | null {
return generateCoMapsUrl(
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation),
profile,
) || null
}
@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react'
import {
ArrowRight, ArrowUpRight, BedDouble, CalendarRange, ChevronRight, LogIn, LogOut,
ArrowRight, ArrowUpRight, BedDouble, CalendarRange, ChevronRight, Compass, LogIn, LogOut,
MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2,
Car, Footprints, Zap, RotateCcw,
} from 'lucide-react'
@@ -186,6 +186,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
<PlanAction icon={TrainFront} label={t('mobileTrip.addTransportShort')} onClick={tl.addTransport} />
<PlanAction icon={Route} label={t('dayplan.optimize')} onClick={() => void tl.optimize()} />
<PlanAction icon={ArrowUpRight} label={t('mobileTrip.googleMaps')} onClick={tl.exportGoogleMaps} />
<PlanAction icon={Compass} label={t('mobileTrip.coMaps')} onClick={tl.exportCoMaps} />
</div>
)}
</div>
@@ -5,7 +5,7 @@ import { assignmentsApi, reservationsApi, weatherApi } from '../../../../api/cli
import { usePluginStore } from '../../../../store/pluginStore'
import { getDayBookendHotels } from '../../../../utils/dayOrder'
import { getDisplayTimeForDay, getMergedItems, getTransportForDay } from '../../../../utils/dayMerge'
import { dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute'
import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute'
import {
buildPlanRows, breaksChronology, findUpNext, hotelChipsForDay, hotelLegsForDay, itemHasTime,
type HotelLegs, type PlanRow, type TransportEntry,
@@ -307,6 +307,15 @@ export function useMPlanTimeline(planner: TripPlanner) {
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}, [day, dayAssignments, days, tripAccommodations, settings])
const exportCoMaps = useCallback(() => {
if (!day) return
const url = dayCoMapsUrl(
day, days, dayAssignments, tripAccommodations, settings.optimize_from_accommodation !== false,
day.default_transport_mode ?? routeProfile,
)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}, [day, dayAssignments, days, tripAccommodations, settings, routeProfile])
const renameDay = useCallback((title: string) => {
if (!day) return
void planner.handleUpdateDayTitle(day.id, title.trim())
@@ -352,7 +361,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
openTransitKeys, toggleTransit,
moveRow, removeAssignment, editAssignment, editTransport, openTransitJourney,
addPlace, addBooking, addTransport,
optimize, exportGoogleMaps, renameDay, fullPlaceOf,
optimize, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf,
routeModeOptions, setLegMode,
}
}
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
CalendarDays, Car, Footprints, Hotel, Pencil, Plus, RotateCcw,
CalendarDays, Car, Compass, Footprints, Hotel, Pencil, Plus, RotateCcw,
Route as RouteIcon, TramFront, Zap,
} from 'lucide-react'
import type { WeatherResult } from '@trek/shared'
@@ -14,7 +14,7 @@ import { useDayNotes } from '../../../../hooks/useDayNotes'
import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
import { splitReservationDateTime } from '../../../../utils/formatters'
import { dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute'
import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute'
import { splitNoteTime } from '../lib/dayNotes'
import { weatherIconFor } from '../plan/planTimelineModel'
import type { Assignment, DayNote, Reservation } from '../../../../types'
@@ -182,6 +182,16 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
// The same day in CoMaps, for navigating it offline (#1904).
const openInCoMaps = () => {
if (!day) return
const url = dayCoMapsUrl(
day, planner.days, dayAssignments, planner.tripAccommodations, optimizeFromAccommodation !== false,
day.default_transport_mode ?? planner.routeProfile,
)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
const addAccommodation = () => {
if (!day) return
shell.openSheet('accommodation', { dayId: day.id })
@@ -327,6 +337,16 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
{t('planner.openGoogleMaps')}
</button>
)}
{routable && (
<button
type="button"
onClick={openInCoMaps}
className={`flex items-center gap-[5px] rounded-full px-3 py-[7px] text-[0.75rem] font-semibold text-m-ink ${INNER_CLS}`}
>
<Compass size={13} strokeWidth={2} />
{t('planner.openCoMaps')}
</button>
)}
{canEditDays && dayAssignments.length >= 3 && (
<button
type="button"
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'الملف التالي',
'mobileTrip.filesPrev': 'الملف السابق',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'تنزيل .ics',
'mobileTrip.icsSubscribe': 'الاشتراك في التقويم',
'mobileTrip.icsSubscribeSub': 'تحديث تلقائي في تطبيق التقويم لديك',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'المسار',
'planner.optimize': 'تحسين',
'planner.openGoogleMaps': 'فتح في Google Maps',
'planner.openCoMaps': 'فتح في CoMaps',
'planner.selectDayHint': 'اختر يومًا من القائمة اليسرى لعرض خطة اليوم',
'planner.noPlacesForDay': 'لا توجد أماكن لهذا اليوم بعد',
'planner.addPlacesLink': 'إضافة أماكن ←',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Próximo arquivo',
'mobileTrip.filesPrev': 'Arquivo anterior',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Baixar .ics',
'mobileTrip.icsSubscribe': 'Assinar calendário',
'mobileTrip.icsSubscribeSub': 'Atualiza automaticamente no seu app de calendário',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Rota',
'planner.optimize': 'Otimizar',
'planner.openGoogleMaps': 'Abrir no Google Maps',
'planner.openCoMaps': 'Abrir no CoMaps',
'planner.selectDayHint': 'Selecione um dia na lista à esquerda para ver o plano do dia',
'planner.noPlacesForDay': 'Nenhum lugar neste dia ainda',
'planner.addPlacesLink': 'Adicionar lugares →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Fitxer següent',
'mobileTrip.filesPrev': 'Fitxer anterior',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Baixa .ics',
'mobileTrip.icsSubscribe': 'Subscriu-te al calendari',
'mobileTrip.icsSubscribeSub': "S'actualitza automàticament a la teva app de calendari",
+1
View File
@@ -48,6 +48,7 @@ const planner: TranslationStrings = {
'planner.route': 'Ruta',
'planner.optimize': 'Optimitza',
'planner.openGoogleMaps': 'Obre a Google Maps',
'planner.openCoMaps': 'Obre a CoMaps',
'planner.selectDayHint': "Selecciona un dia de la llista de l'esquerra per veure el seu pla",
'planner.noPlacesForDay': 'Encara no hi ha llocs per a aquest dia',
'planner.addPlacesLink': 'Afegeix llocs →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Další soubor',
'mobileTrip.filesPrev': 'Předchozí soubor',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Stáhnout .ics',
'mobileTrip.icsSubscribe': 'Přihlásit se k odběru kalendáře',
'mobileTrip.icsSubscribeSub': 'Automaticky se aktualizuje ve vaší kalendářové aplikaci',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Trasa',
'planner.optimize': 'Optimalizovat',
'planner.openGoogleMaps': 'Otevřít v Google Mapách',
'planner.openCoMaps': 'Otevřít v CoMaps',
'planner.selectDayHint': 'Vyberte den ze seznamu vlevo pro zobrazení denního plánu',
'planner.noPlacesForDay': 'Zatím žádná místa pro tento den',
'planner.addPlacesLink': 'Přidat místa →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Nächste Datei',
'mobileTrip.filesPrev': 'Vorherige Datei',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '.ics herunterladen',
'mobileTrip.icsSubscribe': 'Kalender abonnieren',
'mobileTrip.icsSubscribeSub': 'Aktualisiert sich automatisch in deiner Kalender-App',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Route',
'planner.optimize': 'Optimieren',
'planner.openGoogleMaps': 'In Google Maps öffnen',
'planner.openCoMaps': 'In CoMaps öffnen',
'planner.selectDayHint': 'Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen',
'planner.noPlacesForDay': 'Noch keine Orte für diesen Tag',
'planner.addPlacesLink': 'Orte hinzufügen →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Next file',
'mobileTrip.filesPrev': 'Previous file',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Download .ics',
'mobileTrip.icsSubscribe': 'Subscribe to calendar',
'mobileTrip.icsSubscribeSub': 'Auto-updates in your calendar app',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Route',
'planner.optimize': 'Optimize',
'planner.openGoogleMaps': 'Open in Google Maps',
'planner.openCoMaps': 'Open in CoMaps',
'planner.selectDayHint': 'Select a day from the left list to see the day plan',
'planner.noPlacesForDay': 'No places for this day yet',
'planner.addPlacesLink': 'Add places →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Archivo siguiente',
'mobileTrip.filesPrev': 'Archivo anterior',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Descargar .ics',
'mobileTrip.icsSubscribe': 'Suscribirse al calendario',
'mobileTrip.icsSubscribeSub': 'Se actualiza automáticamente en tu app de calendario',
+1
View File
@@ -48,6 +48,7 @@ const planner: TranslationStrings = {
'planner.route': 'Ruta',
'planner.optimize': 'Optimizar',
'planner.openGoogleMaps': 'Abrir en Google Maps',
'planner.openCoMaps': 'Abrir en CoMaps',
'planner.selectDayHint': 'Selecciona un día de la lista izquierda para ver su plan',
'planner.noPlacesForDay': 'Aún no hay lugares para este día',
'planner.addPlacesLink': 'Añadir lugares →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Fichier suivant',
'mobileTrip.filesPrev': 'Fichier précédent',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Télécharger le .ics',
'mobileTrip.icsSubscribe': "S'abonner au calendrier",
'mobileTrip.icsSubscribeSub': 'Se met à jour automatiquement dans votre application calendrier',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Itinéraire',
'planner.optimize': 'Optimiser',
'planner.openGoogleMaps': 'Ouvrir dans Google Maps',
'planner.openCoMaps': 'Ouvrir dans CoMaps',
'planner.selectDayHint': 'Sélectionnez un jour dans la liste de gauche pour voir le plan du jour',
'planner.noPlacesForDay': 'Aucun lieu pour ce jour',
'planner.addPlacesLink': 'Ajouter des lieux →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Επόμενο αρχείο',
'mobileTrip.filesPrev': 'Προηγούμενο αρχείο',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Λήψη .ics',
'mobileTrip.icsSubscribe': 'Εγγραφή στο ημερολόγιο',
'mobileTrip.icsSubscribeSub': 'Ενημερώνεται αυτόματα στην εφαρμογή ημερολογίου σας',
+1
View File
@@ -48,6 +48,7 @@ const planner: TranslationStrings = {
'planner.route': 'Διαδρομή',
'planner.optimize': 'Βελτιστοποίηση',
'planner.openGoogleMaps': 'Άνοιγμα στο Google Maps',
'planner.openCoMaps': 'Άνοιγμα στο CoMaps',
'planner.selectDayHint': 'Επιλέξτε μια ημέρα από την αριστερή λίστα για να δείτε το πρόγραμμα',
'planner.noPlacesForDay': 'Δεν υπάρχουν τοποθεσίες για αυτή την ημέρα ακόμη',
'planner.addPlacesLink': 'Προσθήκη τοποθεσιών \u2192',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Következő fájl',
'mobileTrip.filesPrev': 'Előző fájl',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '.ics letöltése',
'mobileTrip.icsSubscribe': 'Feliratkozás a naptárra',
'mobileTrip.icsSubscribeSub': 'Automatikusan frissül a naptáralkalmazásodban',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Útvonal',
'planner.optimize': 'Optimalizálás',
'planner.openGoogleMaps': 'Megnyitás a Google Térképben',
'planner.openCoMaps': 'Megnyitás a CoMaps-ben',
'planner.selectDayHint': 'Válassz egy napot a bal oldali listából a napi terv megtekintéséhez',
'planner.noPlacesForDay': 'Még nincsenek helyek erre a napra',
'planner.addPlacesLink': 'Helyek hozzáadása →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'File berikutnya',
'mobileTrip.filesPrev': 'File sebelumnya',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Unduh .ics',
'mobileTrip.icsSubscribe': 'Berlangganan kalender',
'mobileTrip.icsSubscribeSub': 'Otomatis diperbarui di aplikasi kalender kamu',
+1
View File
@@ -48,6 +48,7 @@ const planner: TranslationStrings = {
'planner.route': 'Rute',
'planner.optimize': 'Optimalkan',
'planner.openGoogleMaps': 'Buka di Google Maps',
'planner.openCoMaps': 'Buka di CoMaps',
'planner.selectDayHint': 'Pilih hari dari daftar kiri untuk melihat rencana hari',
'planner.noPlacesForDay': 'Belum ada tempat untuk hari ini',
'planner.addPlacesLink': 'Tambah tempat →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'File successivo',
'mobileTrip.filesPrev': 'File precedente',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Scarica .ics',
'mobileTrip.icsSubscribe': 'Abbonati al calendario',
'mobileTrip.icsSubscribeSub': 'Si aggiorna automaticamente nella tua app calendario',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Percorso',
'planner.optimize': 'Ottimizza',
'planner.openGoogleMaps': 'Apri in Google Maps',
'planner.openCoMaps': 'Apri in CoMaps',
'planner.selectDayHint': "Seleziona un giorno dall'elenco a sinistra per vedere il programma",
'planner.noPlacesForDay': 'Ancora nessun luogo per questo giorno',
'planner.addPlacesLink': 'Aggiungi luoghi →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': '次のファイル',
'mobileTrip.filesPrev': '前のファイル',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '.ics をダウンロード',
'mobileTrip.icsSubscribe': 'カレンダーを購読',
'mobileTrip.icsSubscribeSub': 'カレンダーアプリで自動的に更新されます',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'ルート',
'planner.optimize': '最適化',
'planner.openGoogleMaps': 'Googleマップで開く',
'planner.openCoMaps': 'CoMapsで開く',
'planner.selectDayHint': '左の一覧から日を選択すると、日別計画が表示されます',
'planner.noPlacesForDay': 'この日の場所はまだありません',
'planner.addPlacesLink': '場所を追加 →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': '다음 파일',
'mobileTrip.filesPrev': '이전 파일',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '.ics 다운로드',
'mobileTrip.icsSubscribe': '캘린더 구독',
'mobileTrip.icsSubscribeSub': '캘린더 앱에서 자동으로 업데이트됩니다',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': '경로',
'planner.optimize': '최적화',
'planner.openGoogleMaps': 'Google Maps에서 열기',
'planner.openCoMaps': 'CoMaps에서 열기',
'planner.selectDayHint': '왼쪽 목록에서 날을 선택하여 일별 계획을 보세요',
'planner.noPlacesForDay': '이 날에 아직 장소가 없습니다',
'planner.addPlacesLink': '장소 추가 →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Volgend bestand',
'mobileTrip.filesPrev': 'Vorig bestand',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '.ics downloaden',
'mobileTrip.icsSubscribe': 'Abonneren op kalender',
'mobileTrip.icsSubscribeSub': 'Wordt automatisch bijgewerkt in je agenda-app',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Route',
'planner.optimize': 'Optimaliseren',
'planner.openGoogleMaps': 'Openen in Google Maps',
'planner.openCoMaps': 'Openen in CoMaps',
'planner.selectDayHint': 'Selecteer een dag uit de lijst links om het dagplan te bekijken',
'planner.noPlacesForDay': 'Nog geen plaatsen voor deze dag',
'planner.addPlacesLink': 'Plaatsen toevoegen →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Następny plik',
'mobileTrip.filesPrev': 'Poprzedni plik',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Pobierz .ics',
'mobileTrip.icsSubscribe': 'Subskrybuj kalendarz',
'mobileTrip.icsSubscribeSub': 'Automatycznie aktualizuje się w aplikacji kalendarza',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Trasa',
'planner.optimize': 'Optymalizuj',
'planner.openGoogleMaps': 'Otwórz w Google Maps',
'planner.openCoMaps': 'Otwórz w CoMaps',
'planner.selectDayHint': 'Wybierz dzień z listy po lewej, aby zobaczyć jego plan',
'planner.noPlacesForDay': 'Brak miejsc dla tego dnia',
'planner.addPlacesLink': 'Dodaj miejsca →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Следующий файл',
'mobileTrip.filesPrev': 'Предыдущий файл',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Скачать .ics',
'mobileTrip.icsSubscribe': 'Подписаться на календарь',
'mobileTrip.icsSubscribeSub': 'Автоматически обновляется в вашем календаре',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Маршрут',
'planner.optimize': 'Оптимизировать',
'planner.openGoogleMaps': 'Открыть в Google Maps',
'planner.openCoMaps': 'Открыть в CoMaps',
'planner.selectDayHint': 'Выберите день из списка слева для просмотра плана дня',
'planner.noPlacesForDay': 'На этот день мест пока нет',
'planner.addPlacesLink': 'Добавить места →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Nästa fil',
'mobileTrip.filesPrev': 'Föregående fil',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Ladda ner .ics',
'mobileTrip.icsSubscribe': 'Prenumerera på kalender',
'mobileTrip.icsSubscribeSub': 'Uppdateras automatiskt i din kalenderapp',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Rutt',
'planner.optimize': 'Optimera',
'planner.openGoogleMaps': 'Öppna i Google Maps',
'planner.openCoMaps': 'Öppna i CoMaps',
'planner.selectDayHint': 'Välj en dag i listan till vänster för att se dagsplanen',
'planner.noPlacesForDay': 'Det finns inga platser för denna dagen ännu',
'planner.addPlacesLink': 'Lägg till platser →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Sonraki dosya',
'mobileTrip.filesPrev': 'Önceki dosya',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '.ics indir',
'mobileTrip.icsSubscribe': 'Takvime abone ol',
'mobileTrip.icsSubscribeSub': 'Takvim uygulamanızda otomatik güncellenir',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Rota',
'planner.optimize': 'Optimize et',
'planner.openGoogleMaps': "Google Haritalar'da aç",
'planner.openCoMaps': "CoMaps'te aç",
'planner.selectDayHint': 'Gün planını görmek için soldaki listeden bir gün seçin',
'planner.noPlacesForDay': 'Bu gün için henüz yer yok',
'planner.addPlacesLink': 'Yer ekle →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Наступний файл',
'mobileTrip.filesPrev': 'Попередній файл',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Завантажити .ics',
'mobileTrip.icsSubscribe': 'Підписатися на календар',
'mobileTrip.icsSubscribeSub': 'Автоматично оновлюється у вашому календарі',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Маршрут',
'planner.optimize': 'Оптимізувати',
'planner.openGoogleMaps': 'Відкрити в Google Maps',
'planner.openCoMaps': 'Відкрити в CoMaps',
'planner.selectDayHint': 'Оберіть день зі списку ліворуч для перегляду плану дня',
'planner.noPlacesForDay': 'На цей день місць поки немає',
'planner.addPlacesLink': 'Додати місця →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': 'Tập tin tiếp theo',
'mobileTrip.filesPrev': 'Tập tin trước',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Tải xuống .ics',
'mobileTrip.icsSubscribe': 'Đăng ký lịch',
'mobileTrip.icsSubscribeSub': 'Tự động cập nhật trong ứng dụng lịch của bạn',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Tuyến đường',
'planner.optimize': 'Tối ưu hóa',
'planner.openGoogleMaps': 'Mở trong Google Maps',
'planner.openCoMaps': 'Mở trong CoMaps',
'planner.selectDayHint': 'Chọn một ngày từ danh sách bên trái để xem kế hoạch trong ngày',
'planner.noPlacesForDay': 'Chưa có địa điểm nào cho ngày này',
'planner.addPlacesLink': 'Thêm địa điểm →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': '下一個檔案',
'mobileTrip.filesPrev': '上一個檔案',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '下載 .ics',
'mobileTrip.icsSubscribe': '訂閱行事曆',
'mobileTrip.icsSubscribeSub': '在你的行事曆應用程式中自動更新',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': '路線',
'planner.optimize': '最佳化',
'planner.openGoogleMaps': '在 Google Maps 中開啟',
'planner.openCoMaps': '在 CoMaps 中開啟',
'planner.selectDayHint': '從左側列表選擇一天以檢視日程計劃',
'planner.noPlacesForDay': '當天暫無地點',
'planner.addPlacesLink': '新增地點 →',
+1
View File
@@ -16,6 +16,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.filesNext': '下一个文件',
'mobileTrip.filesPrev': '上一个文件',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': '下载 .ics',
'mobileTrip.icsSubscribe': '订阅日历',
'mobileTrip.icsSubscribeSub': '在你的日历应用中自动更新',
+1
View File
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': '路线',
'planner.optimize': '优化',
'planner.openGoogleMaps': '在 Google Maps 中打开',
'planner.openCoMaps': '在 CoMaps 中打开',
'planner.selectDayHint': '从左侧列表选择一天以查看日程计划',
'planner.noPlacesForDay': '当天暂无地点',
'planner.addPlacesLink': '添加地点 →',