feat(dayplan): choose the travel mode per segment (#1281) (#1651)

* feat(dayplan): persist per-leg and per-day travel mode (#1281)

The day-plan route used one transport mode for the whole day, held only in
ephemeral state. Store it instead: leg_transport_mode on an assignment is
the mode of the leg leaving that stop (null = inherit the day default), and
days gains a default_transport_mode. Both nullable, so existing itineraries
are unchanged.

Adds PUT /assignments/:id/transport and PUT /days/:id/transport — their own
endpoints so setting the day default can't wipe notes/title the way the
general day update would — mirroring the assignment_time path.

* feat(dayplan): choose the travel mode per segment (#1281)

Each leg between two stops now routes with its own mode, so a day can mix
walking, driving and plugin-routed legs. Tap the connector between two
places to change that leg's mode (desktop popup / mobile sheet); the
whole-day Foot/Car picker sets the persisted day default and leaves
explicitly-set legs alone. The connector and map redraw in the chosen mode,
and each route segment is tagged with the mode it was drawn in.

New strings across all 23 locales.
This commit is contained in:
Maurice
2026-07-21 23:36:53 +02:00
committed by GitHub
parent ac723744ab
commit c5360be242
44 changed files with 348 additions and 56 deletions
+5 -1
View File
@@ -27,7 +27,7 @@ import {
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
@@ -388,6 +388,8 @@ export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
// Whole-day default route mode (#1281); per-segment leg modes override it.
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
}
@@ -443,6 +445,8 @@ export const assignmentsApi = {
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
updateTransport: (tripId: number | string, id: number, mode: string | null) => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, { transport_mode: mode } satisfies AssignmentTransportRequest).then(r => r.data),
}
export const packingApi = {
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } 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 { assignmentsApi, reservationsApi } from '../../api/client'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
@@ -451,8 +451,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
const planDay = (dayId: number) => {
const merged = mergedItemsMap[dayId] || []
const runs: { id: number; lat: number; lng: number }[][] = []
let cur: { id: number; lat: number; lng: number }[] = []
// Each run point carries the ORIGIN assignment's per-segment travel mode
// (#1281): the leg from this point to the next is routed with `mode` (null =
// inherit the day default). Transport endpoints carry no mode → day default.
const runs: { id: number; lat: number; lng: number; mode?: string | null }[][] = []
let cur: { id: number; lat: number; lng: number; mode?: string | null }[] = []
// A run is only a real drive when it holds an actual place. Two back-to-back
// transports (e.g. two flights on one day) would otherwise pair the first's
// arrival with the second's departure into a phantom airport→airport leg — the
@@ -460,7 +463,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
let curHasPlace = false
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng, mode: it.data.leg_transport_mode ?? null })
curHasPlace = true
} else if (it.type === 'transport') {
const r = it.data
@@ -523,32 +526,42 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// One cached OSRM/plugin call per waypoint pair; shares RouteCalculator's cache.
// tripId/dayId ride along for plugin route profiles (the server access-checks them).
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }, dayId: number): Promise<RouteSegment | undefined> => {
// Resolve a leg's mode (#1281): an explicit per-segment override wins, then
// the day's saved default, then the live picker value. Sticky by design — the
// day default never touches a leg that carries its own mode.
const dayDefaultMode = (dayId: number): string =>
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }, dayId: number, mode: string): Promise<RouteSegment | undefined> => {
try {
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile, tripId, dayId })
return r.legs[0]
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: mode, tripId, dayId })
return r.legs[0] ? { ...r.legs[0], mode } : undefined
} catch { return undefined }
}
for (const dayId of routeDayIds) {
const { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } = planDay(dayId)
const dfMode = dayDefaultMode(dayId)
const dayLegs: Record<number, RouteSegment> = {}
for (const run of runs) {
try {
const r = await calculateRouteWithLegs(run.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: routeProfile, tripId, dayId })
r.legs.forEach((leg, i) => { dayLegs[run[i].id] = leg })
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
// One routing call per LEG, each with its own resolved mode, so a day can
// mix walking/driving/plugin legs. RouteCalculator's cache is keyed per
// profile+coords, so per-leg calls stay cheap (a single-mode day reuses
// the same entries) and each leg is tagged with the mode it was drawn in.
for (let i = 0; i < run.length - 1; i++) {
const seg = await legBetween({ lat: run[i].lat, lng: run[i].lng }, { lat: run[i + 1].lat, lng: run[i + 1].lng }, dayId, run[i].mode || dfMode)
if (seg) dayLegs[run[i].id] = seg
else if (controller.signal.aborted) return
}
}
if (Object.keys(dayLegs).length) legsByDay[dayId] = dayLegs
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
if (wantTop) {
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay!.lat, lng: firstWay!.lng }, dayId)
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay!.lat, lng: firstWay!.lng }, dayId, dfMode)
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
}
if (wantBottom) {
const seg = await legBetween({ lat: lastWay!.lat, lng: lastWay!.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number }, dayId)
const seg = await legBetween({ lat: lastWay!.lat, lng: lastWay!.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number }, dayId, dfMode)
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
}
if (controller.signal.aborted) return
@@ -1339,6 +1352,51 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
expandedRouteDayIds,
setExpandedRouteDayIds,
} = S
// ── Per-segment / per-day travel mode (#1281) ──────────────────────────────
// Icon per mode, matching the route picker (driving→Car, walking→Footprints,
// any plugin profile→Zap).
const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car)
// Set the mode of the leg LEAVING this stop. Optimistic (the connector + map
// recompute from the store), then persisted; null clears the override so the leg
// falls back to the day default. The whole-day picker never writes here — that is
// what keeps a chosen segment sticky against the Foot/Car button (#1281).
const setLegMode = (assignmentId: number, dayId: number, mode: string | null) => {
const key = String(dayId)
if (assignments[key]) {
tripActions.setAssignments({
...assignments,
[key]: assignments[key].map(a => (a.id === assignmentId ? { ...a, leg_transport_mode: mode } : a)),
})
}
assignmentsApi.updateTransport(tripId, assignmentId, mode).catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
tripActions.refreshDays(tripId)
})
}
// The whole-day default (the Car/Foot picker). Persisted so it survives a reload,
// and mirrored into routeProfile so the live map redraws at once. Legs that carry
// their own mode are left untouched.
const setDayDefaultMode = (dayId: number, mode: string) => {
useTripStore.setState(state => ({ days: state.days.map(d => (d.id === dayId ? { ...d, default_transport_mode: mode } : d)) }))
onSetRouteProfile?.(mode)
daysApi.updateTransport(tripId, dayId, mode).catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
tripActions.refreshDays(tripId)
})
}
// Open the mode menu at the clicked connector: every route profile plus a
// "use day default" entry that clears the per-leg override.
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number) => {
ctxMenu.open(e, [
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setLegMode(assignmentId, dayId, o.key) })),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setLegMode(assignmentId, dayId, null) },
])
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
@@ -1995,7 +2053,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)}
</div>
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{routeLegs[day.id]?.[assignment.id] && <RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />}
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id)} style={{ cursor: 'pointer' }}>
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
</div>
) : (
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
))}
</React.Fragment>
)
}
@@ -2461,11 +2525,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
{routeProfileOptions.map(p => {
const ModeIcon = p.key === 'driving' ? Car : p.key === 'walking' ? Footprints : Zap
const active = routeProfile === p.key
const active = (day.default_transport_mode ?? routeProfile) === p.key
return (
<button
key={p.key}
onClick={() => onSetRouteProfile?.(p.key)}
onClick={() => setDayDefaultMode(day.id, p.key)}
aria-label={p.label}
title={p.label}
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
@@ -12,8 +12,10 @@ function profileIcon(profile: string) {
/** Slim travel-time connector shown between two consecutive located stops in a day. */
export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: string }) {
const driving = profile !== 'walking'
const Icon = profileIcon(profile)
// The leg's own mode (#1281) wins over the day-wide fallback for icon + text.
const effProfile = seg.mode ?? profile
const driving = effProfile !== 'walking'
const Icon = profileIcon(effProfile)
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', lineHeight: 1.2 }}>
@@ -52,8 +54,9 @@ export function HotelRouteConnector({
name: string
placement: 'top' | 'bottom'
}) {
const driving = profile !== 'walking'
const Icon = profileIcon(profile)
const effProfile = seg.mode ?? profile
const driving = effProfile !== 'walking'
const Icon = profileIcon(effProfile)
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
const hotelRow = (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
+43 -16
View File
@@ -24,6 +24,11 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Recompute when the selected day's whole-day default mode changes (#1281) —
// including a remote collaborator's change, which arrives as day:updated and only
// touches state.days (otherwise not an effect dependency, so the map/mobile
// connectors would stay in the old mode while the sidebar already switched).
const selectedDayDefaultMode = useTripStore((s) => (selectedDayId ? s.days?.find(d => d.id === selectedDayId)?.default_transport_mode ?? null : null))
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
// hotel) unless the user turned the setting off — same gate as the sidebar.
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
@@ -68,11 +73,13 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Build a unified list of places + transports sorted by effective position.
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null }
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
const entries: Entry[] = [
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
})),
...dayTransports.map(r => {
const { from, to } = getTransportRouteEndpoints(r, dayId)
@@ -95,12 +102,12 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// back-to-back transports (e.g. two flights on one day) would otherwise pair the
// first's arrival point with the second's departure point into a phantom
// [airport → airport] road route — that is the flight itself, not a drive (#1394).
const runs: { lat: number; lng: number }[][] = []
let currentRun: { lat: number; lng: number }[] = []
const runs: { lat: number; lng: number; mode?: string | null }[][] = []
let currentRun: { lat: number; lng: number; mode?: string | null }[] = []
let runHasPlace = false
for (const entry of entries) {
if (entry.kind === 'place') {
currentRun.push({ lat: entry.lat, lng: entry.lng })
currentRun.push({ lat: entry.lat, lng: entry.lng, mode: entry.mode })
runHasPlace = true
} else if (entry.from || entry.to) {
if (entry.from) currentRun.push(entry.from)
@@ -140,7 +147,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
e ? { isPlace: e.kind === 'place', time: e.kind === 'place' ? e.time : null } : undefined
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop))
const runsWithHotel = withHotelBookends(
const runsWithHotel: { lat: number; lng: number; mode?: string | null }[][] = withHotelBookends(
runs,
flatPts[0],
flatPts[flatPts.length - 1],
@@ -171,28 +178,48 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const tripId = useTripStore.getState().trip?.id ?? null
const controller = new AbortController()
routeAbortRef.current = controller
// Per-leg routing (#1281): each leg uses its origin's saved mode, else the
// day's default, else the live picker profile. Legs are concatenated back into
// one polyline per run, and each segment is tagged with the mode it drew in so
// the connector shows the matching icon and duration.
const dayDefaultMode = day?.default_transport_mode || profile
try {
const polylines: [number, number][][] = []
const allLegs: RouteSegment[] = []
const allVias: RouteVia[] = []
for (const run of runsWithHotel) {
try {
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile, tripId, dayId })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
allLegs.push(...r.legs)
if (r.vias) allVias.push(...r.vias)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
// Routing failed for this run — fall back to a straight line, no times.
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
const polyline: [number, number][] = []
// Append a leg's coordinates, dropping the point shared with the previous
// leg so concatenated legs don't leave a duplicate at each junction.
const pushCoords = (coords: [number, number][]) => {
for (const c of coords) {
const last = polyline[polyline.length - 1]
if (last && last[0] === c[0] && last[1] === c[1]) continue
polyline.push(c)
}
}
for (let i = 0; i < run.length - 1; i++) {
const mode = run[i].mode || dayDefaultMode
const straight = (): [number, number][] => [[run[i].lat, run[i].lng], [run[i + 1].lat, run[i + 1].lng]]
try {
const r = await calculateRouteWithLegs([{ lat: run[i].lat, lng: run[i].lng }, { lat: run[i + 1].lat, lng: run[i + 1].lng }], { signal: controller.signal, profile: mode, tripId, dayId })
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight())
if (r.legs[0]) allLegs.push({ ...r.legs[0], mode })
if (r.vias) allVias.push(...r.vias)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
// Routing failed for this leg — fall back to a straight line, no times.
pushCoords(straight())
}
}
if (polyline.length >= 2) polylines.push(polyline)
}
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs); setRouteVias(allVias) }
} catch (err: unknown) {
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
}
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -216,7 +243,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useState, type MouseEvent } from 'react'
import {
ArrowRight, ArrowUpRight, BedDouble, CalendarRange, ChevronRight, LogIn, LogOut,
MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2,
Car, Footprints, Zap, RotateCcw,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useContextMenu, ContextMenu } from '../../../../components/shared/ContextMenu'
import { fmtTransitDuration } from '../../../../components/Planner/transitDisplay'
import { formatTime } from '../../../../utils/formatters'
import { useMPlanTimeline, type MPlanTimelineController } from './useMPlanTimeline'
@@ -31,6 +33,16 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
const { t, trip, can } = planner
const canEdit = can('day_edit', trip)
const editing = shell.mode === 'edit' && canEdit
// Per-segment travel mode (#1281): tap a connector → pick the leg's mode.
const legMenu = useContextMenu()
const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car)
const openLegMenu = (e: MouseEvent, assignmentId: number) => {
legMenu.open(e, [
...tl.routeModeOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => tl.setLegMode(assignmentId, o.key) })),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => tl.setLegMode(assignmentId, null) },
])
}
// Plugin time contributions in the day plan (dayScheduleProvider hook) —
// slotted under their anchor rows, same as the desktop sidebar.
const daySchedule = usePluginDaySchedule(planner.tripId)
@@ -64,6 +76,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
return (
<div className="absolute inset-0">
<ContextMenu menu={legMenu.menu} onClose={legMenu.close} />
{!editing && <UpNextCard tl={tl} t={t} onOpen={openPlace} />}
{editing && <EditHeader tl={tl} planner={planner} shell={shell} />}
@@ -144,7 +157,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
/>
)
case 'conn':
return <ConnRow key={row.key} seg={row.seg} />
return <ConnRow key={row.key} seg={row.seg} onTap={canEdit && row.assignmentId != null ? e => openLegMenu(e, row.assignmentId!) : undefined} />
}
})}
@@ -1,5 +1,5 @@
import { BedDouble, Car, ChevronDown, ChevronUp, Clock, Footprints, Pencil, Route, Ticket, X, Zap } from 'lucide-react'
import type { ReactNode } from 'react'
import type { ReactNode, MouseEvent } from 'react'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
@@ -359,20 +359,20 @@ export function TransitRow({ res, transit, dayId, open, chrome, reorder, onToggl
// ── b4) Walk/drive connector between two located places ─────────────────────
export function ConnRow({ seg }: { seg: RouteSegment }) {
return (
<div className="mt-[5px] flex items-center gap-2 py-px text-[color:var(--m-conn)]">
export function ConnRow({ seg, onTap }: { seg: RouteSegment; onTap?: (e: MouseEvent) => void }) {
// The leg's chosen mode (#1281) drives the icon + duration; tap to change it.
const mode = seg.mode
const Icon = mode === 'walking' ? Footprints : mode?.startsWith('plugin:') ? Zap : Car
const durationText = seg.durationText ?? (mode === 'walking' ? seg.walkingText : seg.drivingText)
const inner = (
<>
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
<span className="inline-flex items-center gap-[3px] whitespace-nowrap font-geist text-[0.59375rem] font-semibold text-m-faint">
<Footprints size={10} strokeWidth={2} />
{seg.walkingText}
<Icon size={10} strokeWidth={2} />
{durationText}
</span>
<span className="whitespace-nowrap font-geist text-[0.59375rem] font-semibold text-m-faint">
· {seg.distanceText} ·
</span>
<span className="inline-flex items-center gap-[3px] whitespace-nowrap font-geist text-[0.59375rem] font-semibold text-m-faint">
<Car size={10} strokeWidth={2} />
{seg.drivingText}
· {seg.distanceText}
</span>
{/* Extra text a plugin route attached to this leg (e.g. "25 min charge"). */}
{seg.noteText && (
@@ -382,8 +382,12 @@ export function ConnRow({ seg }: { seg: RouteSegment }) {
</span>
)}
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
</div>
</>
)
const cls = 'mt-[5px] flex w-full items-center gap-2 py-px text-[color:var(--m-conn)]'
return onTap
? <button type="button" onClick={onTap} className={cls}>{inner}</button>
: <div className={cls}>{inner}</div>
}
// ── b4a) Plugin schedule contribution ("35 min charging at this stop") ───────
@@ -28,7 +28,7 @@ export type PlanRow =
| { key: string; kind: 'transport'; item: MergedItem; res: TransportEntry }
| { key: string; kind: 'transit'; item: MergedItem; res: TransportEntry; transit: TransitMeta }
| { key: string; kind: 'note'; item: MergedItem; note: DayNote }
| { key: string; kind: 'conn'; seg: RouteSegment }
| { key: string; kind: 'conn'; seg: RouteSegment; assignmentId?: number }
export function parseReservationMeta(res: Reservation): Record<string, unknown> {
let meta: unknown = res.metadata
@@ -136,7 +136,9 @@ export function buildPlanRows(opts: {
const to = coordOf(next)
if (to) { seg = takeSegment(from, to); break }
}
if (seg) out.push({ key: `conn-${row.key}`, kind: 'conn', seg })
// The leg's mode is stored on its ORIGIN place assignment (#1281), so carry
// that id for the tap-to-change menu.
if (seg) out.push({ key: `conn-${row.key}`, kind: 'conn', seg, assignmentId: row.kind === 'place' ? row.assignment.id : undefined })
}
return out
}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTripStore } from '../../../../store/tripStore'
import { useRouteCalculation } from '../../../../hooks/useRouteCalculation'
import { reservationsApi, weatherApi } from '../../../../api/client'
import { assignmentsApi, reservationsApi, weatherApi } from '../../../../api/client'
import { usePluginStore } from '../../../../store/pluginStore'
import { getDayBookendHotels } from '../../../../utils/dayOrder'
import { getDisplayTimeForDay, getMergedItems, getTransportForDay } from '../../../../utils/dayMerge'
import { dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute'
@@ -315,6 +316,31 @@ export function useMPlanTimeline(planner: TripPlanner) {
? Math.round(settings.temperature_unit === 'fahrenheit' ? weather.temp * 9 / 5 + 32 : weather.temp)
: null
// ── Per-segment travel mode (#1281) ──
const activePlugins = usePluginStore(s => s.plugins)
const routeModeOptions = useMemo(() => {
const opts: Array<{ key: string; label: string }> = [
{ key: 'driving', label: 'Driving' },
{ key: 'walking', label: 'Walking' },
]
for (const p of activePlugins) for (const prof of p.routeProfiles ?? []) opts.push({ key: `plugin:${p.id}/${prof.id}`, label: prof.label })
return opts
}, [activePlugins])
// Set the mode of the leg leaving a stop — optimistic, then persisted; null clears
// the override back to the day default. Sticky against the whole-day picker.
const setLegMode = useCallback((assignmentId: number, mode: string | null) => {
if (!day) return
const key = String(day.id)
useTripStore.setState(state => ({
assignments: { ...state.assignments, [key]: (state.assignments[key] || []).map(a => (a.id === assignmentId ? { ...a, leg_transport_mode: mode } : a)) },
}))
assignmentsApi.updateTransport(tripId, assignmentId, mode).catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
tripActions.refreshDays(tripId)
})
}, [day, tripId, toast, t, tripActions])
return {
day, rows, hotelLegs, merged, hotelChips, weather, weatherTemp, upNext,
language, timeFormat: settings.time_format,
@@ -322,6 +348,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
moveRow, removeAssignment, editAssignment, editTransport, openTransitJourney,
addPlace, addBooking, addTransport,
optimize, exportGoogleMaps, renameDay, fullPlaceOf,
routeModeOptions, setLegMode,
}
}
+2
View File
@@ -160,6 +160,8 @@ export interface RouteSegment {
durationText?: string
/** Extra text a plugin route attached to this leg (e.g. "25 min charge"). */
noteText?: string
/** The travel mode this leg was routed with (#1281) — drives the connector icon. */
mode?: string
}
/** An intermediate stop a plugin route places on the drawn line (charging stop, rest area). */
@@ -112,7 +112,9 @@ describe('useRouteCalculation', () => {
await act(async () => {});
expect(calculateRouteWithLegs).toHaveBeenCalled();
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
// Each leg is now tagged with the mode it was routed in (#1281); with no
// per-segment override or day default, that resolves to the 'driving' default.
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS.map(s => ({ ...s, mode: 'driving' })));
});
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
+17
View File
@@ -3754,6 +3754,23 @@ function runMigrations(db: Database.Database): void {
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id);');
},
// Per-segment travel mode (#1281): the day-plan route can use a different
// transport mode for each leg. leg_transport_mode on an assignment is the mode
// of the leg LEAVING that stop (NULL = inherit the day default); days gains a
// persisted default_transport_mode so the whole-day choice survives a reload.
// Both nullable → existing itineraries keep today's single-mode behaviour.
() => {
try {
db.exec('ALTER TABLE day_assignments ADD COLUMN leg_transport_mode TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
try {
db.exec('ALTER TABLE days ADD COLUMN default_transport_mode TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
];
if (currentVersion < migrations.length) {
@@ -172,6 +172,24 @@ export class AssignmentOpsController {
return { assignment };
}
@Put(':id/transport')
transport(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { transport_mode?: string | null },
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = this.assignments.setLegTransportMode(id, body.transport_mode ?? null);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
return { assignment };
}
@Put(':id/participants')
setParticipants(
@CurrentUser() user: User,
@@ -81,6 +81,10 @@ export class AssignmentsService {
return svc.updateTime(id, placeTime as never, endTime as never);
}
setLegTransportMode(id: string, mode: string | null) {
return svc.setLegTransportMode(id, mode);
}
setParticipants(id: string, userIds: number[]) {
return svc.setParticipants(id, userIds);
}
+18
View File
@@ -113,6 +113,24 @@ export class DaysController {
return { day };
}
@Put(':id/transport')
transport(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { transport_mode?: string | null },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!this.days.getDay(id, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
const day = this.days.setDefaultTransportMode(id, body.transport_mode ?? null);
this.days.broadcast(tripId, 'day:updated', { day }, socketId);
return { day };
}
@Delete(':id')
remove(
@CurrentUser() user: User,
+4
View File
@@ -51,6 +51,10 @@ export class DaysService {
return dayService.updateDay(id, current, fields);
}
setDefaultTransportMode(id: string, mode: string | null) {
return dayService.setDefaultTransportMode(id, mode);
}
remove(id: string): void {
dayService.deleteDay(id);
}
+12
View File
@@ -40,6 +40,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
notes: a.notes,
assignment_time: a.assignment_time ?? null,
assignment_end_time: a.assignment_end_time ?? null,
leg_transport_mode: a.leg_transport_mode ?? null,
participants,
created_at: a.created_at,
place: {
@@ -201,6 +202,17 @@ export function updateTime(id: string | number, placeTime: string | null, endTim
return getAssignmentWithPlace(Number(id));
}
/**
* Set the travel mode of the leg leaving this stop (#1281). null clears the
* override so the leg falls back to the day's default_transport_mode. This is
* sticky by design: changing the whole-day default never touches a leg that
* carries its own explicit mode.
*/
export function setLegTransportMode(id: string | number, mode: string | null) {
db.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run(mode ?? null, id);
return getAssignmentWithPlace(Number(id));
}
export function setParticipants(assignmentId: string | number, userIds: number[]) {
db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(assignmentId);
if (userIds.length > 0) {
+11
View File
@@ -155,6 +155,17 @@ export function updateDay(id: string | number, current: Day, fields: { notes?: s
return { ...updatedDay, assignments: getAssignmentsForDay(id) };
}
/**
* Set the whole-day default route mode (#1281). Its own endpoint so it can't wipe
* notes/title the way the general day update would, and symmetric with the
* per-leg assignment transport setter. Per-segment leg modes still override it.
*/
export function setDefaultTransportMode(id: string | number, mode: string | null) {
db.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run(mode ?? null, id);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;
return { ...updatedDay, assignments: getAssignmentsForDay(id) };
}
export function deleteDay(id: string | number) {
db.prepare('DELETE FROM days WHERE id = ?').run(id);
}
+1
View File
@@ -95,6 +95,7 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
notes: a.notes,
assignment_time: a.assignment_time ?? null,
assignment_end_time: a.assignment_end_time ?? null,
leg_transport_mode: a.leg_transport_mode ?? null,
participants: participants || [],
created_at: a.created_at,
place: {
+2
View File
@@ -48,6 +48,7 @@ export interface Day {
date?: string | null;
notes?: string | null;
title?: string | null;
default_transport_mode?: string | null;
}
export interface Place {
@@ -107,6 +108,7 @@ export interface DayAssignment {
reservation_datetime?: string | null;
assignment_time?: string | null;
assignment_end_time?: string | null;
leg_transport_mode?: string | null;
created_at?: string;
}
@@ -39,6 +39,9 @@ export const assignmentSchema = z.object({
notes: z.string().nullable().optional(),
assignment_time: z.string().nullable().optional(),
assignment_end_time: z.string().nullable().optional(),
// Per-segment travel mode (#1281): the transport mode of the leg LEAVING this
// stop for the next one. null = inherit the day's default_transport_mode.
leg_transport_mode: z.string().nullable().optional(),
participants: z.array(assignmentParticipantSchema).optional(),
created_at: z.string().optional(),
place: assignmentPlaceSchema,
@@ -68,6 +71,12 @@ export const assignmentTimeRequestSchema = z.object({
});
export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
/** Set the leg's travel mode (a RouteProfileKey, or null to inherit the day default). */
export const assignmentTransportRequestSchema = z.object({
transport_mode: z.string().nullable(),
});
export type AssignmentTransportRequest = z.infer<typeof assignmentTransportRequestSchema>;
export const assignmentParticipantsRequestSchema = z.object({
user_ids: z.array(z.number()),
});
+2
View File
@@ -41,6 +41,8 @@ export const daySchema = z.object({
date: z.string().nullable().optional(),
title: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// Whole-day default route mode (#1281); per-segment leg modes override it.
default_transport_mode: z.string().nullable().optional(),
assignments: z.array(assignmentSchema).optional(),
notes_items: z.array(dayNoteSchema).optional(),
});
+2
View File
@@ -23,6 +23,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'جارٍ الحساب...',
'dayplan.route': 'المسار',
'dayplan.optimize': 'تحسين',
'dayplan.transportMode.change': 'تغيير وسيلة النقل',
'dayplan.transportMode.useDefault': 'استخدام إعداد اليوم الافتراضي',
'dayplan.optimized': 'تم تحسين المسار',
'dayplan.routeError': 'فشل حساب المسار',
'dayplan.toast.needTwoPlaces': 'يلزم مكانان على الأقل لتحسين المسار',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Calculando...',
'dayplan.route': 'Rota',
'dayplan.optimize': 'Otimizar',
'dayplan.transportMode.change': 'Alterar meio de transporte',
'dayplan.transportMode.useDefault': 'Usar padrão do dia',
'dayplan.optimized': 'Rota otimizada',
'dayplan.routeError': 'Falha ao calcular a rota',
'dayplan.toast.needTwoPlaces': 'São necessários pelo menos dois lugares para otimizar a rota',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Calculant...',
'dayplan.route': 'Ruta',
'dayplan.optimize': 'Optimitza',
'dayplan.transportMode.change': 'Canvia el mitjà de transport',
'dayplan.transportMode.useDefault': 'Usa el valor per defecte del dia',
'dayplan.optimized': 'Ruta optimitzada',
'dayplan.routeError': "No s'ha pogut calcular la ruta",
'dayplan.toast.needTwoPlaces': 'Calen almenys dos llocs per optimitzar la ruta',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Počítání...',
'dayplan.route': 'Trasa',
'dayplan.optimize': 'Optimalizovat',
'dayplan.transportMode.change': 'Změnit způsob dopravy',
'dayplan.transportMode.useDefault': 'Použít výchozí dne',
'dayplan.optimized': 'Trasa optimalizována',
'dayplan.routeError': 'Nepodařilo se vypočítat trasu',
'dayplan.toast.needTwoPlaces': 'Pro optimalizaci trasy jsou potřeba alespoň dvě místa',
+2
View File
@@ -26,6 +26,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Berechne...',
'dayplan.route': 'Route',
'dayplan.optimize': 'Optimieren',
'dayplan.transportMode.change': 'Verkehrsmittel ändern',
'dayplan.transportMode.useDefault': 'Tages-Standard verwenden',
'dayplan.optimized': 'Route optimiert',
'dayplan.routeError': 'Fehler bei der Routenberechnung',
'dayplan.toast.needTwoPlaces': 'Mindestens zwei Orte für Routenoptimierung nötig',
+2
View File
@@ -26,6 +26,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Calculating...',
'dayplan.route': 'Route',
'dayplan.optimize': 'Optimize',
'dayplan.transportMode.change': 'Change travel mode',
'dayplan.transportMode.useDefault': 'Use day default',
'dayplan.optimized': 'Route optimized',
'dayplan.routeError': 'Failed to calculate route',
'dayplan.toast.needTwoPlaces': 'At least two places needed for route optimization',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Calculando...',
'dayplan.route': 'Ruta',
'dayplan.optimize': 'Optimizar',
'dayplan.transportMode.change': 'Cambiar medio de transporte',
'dayplan.transportMode.useDefault': 'Usar predet. del día',
'dayplan.optimized': 'Ruta optimizada',
'dayplan.routeError': 'No se pudo calcular la ruta',
'dayplan.toast.needTwoPlaces': 'Se necesitan al menos dos lugares para optimizar la ruta',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Calcul en cours…',
'dayplan.route': 'Itinéraire',
'dayplan.optimize': 'Optimiser',
'dayplan.transportMode.change': 'Changer de mode de transport',
'dayplan.transportMode.useDefault': 'Utiliser le mode du jour',
'dayplan.optimized': 'Itinéraire optimisé',
'dayplan.routeError': "Impossible de calculer l'itinéraire",
'dayplan.toast.needTwoPlaces': "Au moins deux lieux nécessaires pour optimiser l'itinéraire",
+2
View File
@@ -26,6 +26,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Υπολογισμός...',
'dayplan.route': 'Διαδρομή',
'dayplan.optimize': 'Βελτιστοποίηση',
'dayplan.transportMode.change': 'Αλλαγή μέσου μεταφοράς',
'dayplan.transportMode.useDefault': 'Χρήση προεπιλογής ημέρας',
'dayplan.optimized': 'Η διαδρομή βελτιστοποιήθηκε',
'dayplan.routeError': 'Αποτυχία υπολογισμού διαδρομής',
'dayplan.toast.needTwoPlaces': 'Χρειάζονται τουλάχιστον δύο μέρη για βελτιστοποίηση διαδρομής',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Számítás...',
'dayplan.route': 'Útvonal',
'dayplan.optimize': 'Optimalizálás',
'dayplan.transportMode.change': 'Közlekedési mód módosítása',
'dayplan.transportMode.useDefault': 'Napi alapértelmezett',
'dayplan.optimized': 'Útvonal optimalizálva',
'dayplan.routeError': 'Nem sikerült kiszámítani az útvonalat',
'dayplan.toast.needTwoPlaces': 'Legalább két hely szükséges az útvonal-optimalizáláshoz',
+2
View File
@@ -24,6 +24,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Menghitung...',
'dayplan.route': 'Rute',
'dayplan.optimize': 'Optimalkan',
'dayplan.transportMode.change': 'Ubah moda transportasi',
'dayplan.transportMode.useDefault': 'Gunakan bawaan hari',
'dayplan.optimized': 'Rute dioptimalkan',
'dayplan.routeError': 'Gagal menghitung rute',
'dayplan.toast.needTwoPlaces': 'Diperlukan minimal dua tempat untuk optimasi rute',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Calcolo in corso...',
'dayplan.route': 'Percorso',
'dayplan.optimize': 'Ottimizza',
'dayplan.transportMode.change': 'Cambia mezzo di trasporto',
'dayplan.transportMode.useDefault': 'Usa predefinito del giorno',
'dayplan.optimized': 'Percorso ottimizzato',
'dayplan.routeError': 'Impossibile calcolare il percorso',
'dayplan.toast.needTwoPlaces': "Servono almeno due luoghi per l'ottimizzazione del percorso",
+2
View File
@@ -26,6 +26,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': '計算中...',
'dayplan.route': 'ルート',
'dayplan.optimize': '最適化',
'dayplan.transportMode.change': '移動手段を変更',
'dayplan.transportMode.useDefault': '1日のデフォルトを使用',
'dayplan.optimized': 'ルートを最適化しました',
'dayplan.routeError': 'ルートの計算に失敗しました',
'dayplan.toast.needTwoPlaces': 'ルート最適化には2つ以上の場所が必要です',
+2
View File
@@ -26,6 +26,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': '계산 중...',
'dayplan.route': '경로',
'dayplan.optimize': '최적화',
'dayplan.transportMode.change': '이동 수단 변경',
'dayplan.transportMode.useDefault': '하루 기본값 사용',
'dayplan.optimized': '경로가 최적화되었습니다',
'dayplan.routeError': '경로 계산 실패',
'dayplan.toast.needTwoPlaces': '경로 최적화에는 최소 두 개의 장소가 필요합니다',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Berekenen...',
'dayplan.route': 'Route',
'dayplan.optimize': 'Optimaliseren',
'dayplan.transportMode.change': 'Vervoerswijze wijzigen',
'dayplan.transportMode.useDefault': 'Dagstandaard gebruiken',
'dayplan.optimized': 'Route geoptimaliseerd',
'dayplan.routeError': 'Route berekenen mislukt',
'dayplan.toast.needTwoPlaces': 'Minimaal twee plaatsen nodig voor route-optimalisatie',
+2
View File
@@ -25,6 +25,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Obliczanie...',
'dayplan.route': 'Trasa',
'dayplan.optimize': 'Optymalizuj',
'dayplan.transportMode.change': 'Zmień środek transportu',
'dayplan.transportMode.useDefault': 'Użyj domyślnego dnia',
'dayplan.optimized': 'Trasa została zoptymalizowana',
'dayplan.routeError': 'Nie udało się obliczyć trasy',
'dayplan.toast.needTwoPlaces': 'Potrzeba co najmniej dwóch miejsc, aby zoptymalizować trasę',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Расчёт...',
'dayplan.route': 'Маршрут',
'dayplan.optimize': 'Оптимизировать',
'dayplan.transportMode.change': 'Изменить способ передвижения',
'dayplan.transportMode.useDefault': 'Использовать по умолчанию для дня',
'dayplan.optimized': 'Маршрут оптимизирован',
'dayplan.routeError': 'Не удалось рассчитать маршрут',
'dayplan.toast.needTwoPlaces': 'Для оптимизации маршрута нужно минимум два места',
+2
View File
@@ -27,6 +27,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Beräknar...',
'dayplan.route': 'Rutt',
'dayplan.optimize': 'Optimera',
'dayplan.transportMode.change': 'Ändra färdsätt',
'dayplan.transportMode.useDefault': 'Använd dagens standard',
'dayplan.optimized': 'Rutt optimerad',
'dayplan.routeError': 'Det gick inte att beräkna rutten',
'dayplan.toast.needTwoPlaces': 'Minst två platser krävs för ruttoptimering',
+2
View File
@@ -26,6 +26,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Hesaplanıyor...',
'dayplan.route': 'Rota',
'dayplan.optimize': 'Optimize et',
'dayplan.transportMode.change': 'Ulaşım türünü değiştir',
'dayplan.transportMode.useDefault': 'Günün varsayılanını kullan',
'dayplan.optimized': 'Rota optimize edildi',
'dayplan.routeError': 'Rota hesaplanamadı',
'dayplan.toast.needTwoPlaces': 'Rota optimizasyonu için en az iki yer gerekli',
+2
View File
@@ -17,6 +17,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Розрахунок...',
'dayplan.route': 'Маршрут',
'dayplan.optimize': 'Оптимізувати',
'dayplan.transportMode.change': 'Змінити спосіб пересування',
'dayplan.transportMode.useDefault': 'Використати типовий для дня',
'dayplan.optimized': 'Маршрут оптимізовано',
'dayplan.routeError': 'Не вдалося розрахувати маршрут',
'dayplan.toast.needTwoPlaces': 'Для оптимизации маршрута нужно минимум два места',
+2
View File
@@ -26,6 +26,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': 'Đang tính...',
'dayplan.route': 'Chỉ đường',
'dayplan.optimize': 'Tối ưu hóa',
'dayplan.transportMode.change': 'Đổi phương tiện',
'dayplan.transportMode.useDefault': 'Dùng mặc định của ngày',
'dayplan.optimized': 'Tuyến đường được tối ưu hóa',
'dayplan.routeError': 'Không tính được lộ trình',
'dayplan.toast.needTwoPlaces': 'Cần ít nhất hai địa điểm để tối ưu hóa tuyến đường',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': '計算中...',
'dayplan.route': '路線',
'dayplan.optimize': '最佳化',
'dayplan.transportMode.change': '變更交通方式',
'dayplan.transportMode.useDefault': '使用當日預設',
'dayplan.optimized': '路線已最佳化',
'dayplan.routeError': '路線計算失敗',
'dayplan.toast.needTwoPlaces': '路線最佳化至少需要兩個地點',
+2
View File
@@ -15,6 +15,8 @@ const dayplan: TranslationStrings = {
'dayplan.calculating': '计算中...',
'dayplan.route': '路线',
'dayplan.optimize': '优化',
'dayplan.transportMode.change': '更改出行方式',
'dayplan.transportMode.useDefault': '使用当日默认',
'dayplan.optimized': '路线已优化',
'dayplan.routeError': '路线计算失败',
'dayplan.toast.needTwoPlaces': '路线优化至少需要两个地点',