mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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': 'تحديث تلقائي في تطبيق التقويم لديك',
|
||||
|
||||
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
|
||||
'planner.route': 'المسار',
|
||||
'planner.optimize': 'تحسين',
|
||||
'planner.openGoogleMaps': 'فتح في Google Maps',
|
||||
'planner.openCoMaps': 'فتح في CoMaps',
|
||||
'planner.selectDayHint': 'اختر يومًا من القائمة اليسرى لعرض خطة اليوم',
|
||||
'planner.noPlacesForDay': 'لا توجد أماكن لهذا اليوم بعد',
|
||||
'planner.addPlacesLink': 'إضافة أماكن ←',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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': 'Ενημερώνεται αυτόματα στην εφαρμογή ημερολογίου σας',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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': 'カレンダーアプリで自動的に更新されます',
|
||||
|
||||
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
|
||||
'planner.route': 'ルート',
|
||||
'planner.optimize': '最適化',
|
||||
'planner.openGoogleMaps': 'Googleマップで開く',
|
||||
'planner.openCoMaps': 'CoMapsで開く',
|
||||
'planner.selectDayHint': '左の一覧から日を選択すると、日別計画が表示されます',
|
||||
'planner.noPlacesForDay': 'この日の場所はまだありません',
|
||||
'planner.addPlacesLink': '場所を追加 →',
|
||||
|
||||
@@ -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': '캘린더 앱에서 자동으로 업데이트됩니다',
|
||||
|
||||
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
|
||||
'planner.route': '경로',
|
||||
'planner.optimize': '최적화',
|
||||
'planner.openGoogleMaps': 'Google Maps에서 열기',
|
||||
'planner.openCoMaps': 'CoMaps에서 열기',
|
||||
'planner.selectDayHint': '왼쪽 목록에서 날을 선택하여 일별 계획을 보세요',
|
||||
'planner.noPlacesForDay': '이 날에 아직 장소가 없습니다',
|
||||
'planner.addPlacesLink': '장소 추가 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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': 'Автоматически обновляется в вашем календаре',
|
||||
|
||||
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
|
||||
'planner.route': 'Маршрут',
|
||||
'planner.optimize': 'Оптимизировать',
|
||||
'planner.openGoogleMaps': 'Открыть в Google Maps',
|
||||
'planner.openCoMaps': 'Открыть в CoMaps',
|
||||
'planner.selectDayHint': 'Выберите день из списка слева для просмотра плана дня',
|
||||
'planner.noPlacesForDay': 'На этот день мест пока нет',
|
||||
'planner.addPlacesLink': 'Добавить места →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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': 'Автоматично оновлюється у вашому календарі',
|
||||
|
||||
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
|
||||
'planner.route': 'Маршрут',
|
||||
'planner.optimize': 'Оптимізувати',
|
||||
'planner.openGoogleMaps': 'Відкрити в Google Maps',
|
||||
'planner.openCoMaps': 'Відкрити в CoMaps',
|
||||
'planner.selectDayHint': 'Оберіть день зі списку ліворуч для перегляду плану дня',
|
||||
'planner.noPlacesForDay': 'На цей день місць поки немає',
|
||||
'planner.addPlacesLink': 'Додати місця →',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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': '在你的行事曆應用程式中自動更新',
|
||||
|
||||
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
|
||||
'planner.route': '路線',
|
||||
'planner.optimize': '最佳化',
|
||||
'planner.openGoogleMaps': '在 Google Maps 中開啟',
|
||||
'planner.openCoMaps': '在 CoMaps 中開啟',
|
||||
'planner.selectDayHint': '從左側列表選擇一天以檢視日程計劃',
|
||||
'planner.noPlacesForDay': '當天暫無地點',
|
||||
'planner.addPlacesLink': '新增地點 →',
|
||||
|
||||
@@ -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': '在你的日历应用中自动更新',
|
||||
|
||||
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
|
||||
'planner.route': '路线',
|
||||
'planner.optimize': '优化',
|
||||
'planner.openGoogleMaps': '在 Google Maps 中打开',
|
||||
'planner.openCoMaps': '在 CoMaps 中打开',
|
||||
'planner.selectDayHint': '从左侧列表选择一天以查看日程计划',
|
||||
'planner.noPlacesForDay': '当天暂无地点',
|
||||
'planner.addPlacesLink': '添加地点 →',
|
||||
|
||||
Reference in New Issue
Block a user