From c5360be2420a0a6ba0beeedc67ae9444e0608445 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:36:53 +0200 Subject: [PATCH] feat(dayplan): choose the travel mode per segment (#1281) (#1651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- client/src/api/client.ts | 6 +- .../src/components/Planner/DayPlanSidebar.tsx | 98 +++++++++++++++---- .../Planner/DayPlanSidebarRouteConnector.tsx | 11 ++- client/src/hooks/useRouteCalculation.ts | 59 ++++++++--- .../screens/trip/plan/MPlanTimeline.tsx | 17 +++- .../screens/trip/plan/MPlanTimelineRows.tsx | 28 +++--- .../screens/trip/plan/planTimelineModel.ts | 6 +- .../screens/trip/plan/useMPlanTimeline.ts | 29 +++++- client/src/types.ts | 2 + .../hooks/useRouteCalculation.test.ts | 4 +- server/src/db/migrations.ts | 17 ++++ .../assignments/assignments.controller.ts | 18 ++++ .../nest/assignments/assignments.service.ts | 4 + server/src/nest/days/days.controller.ts | 18 ++++ server/src/nest/days/days.service.ts | 4 + server/src/services/assignmentService.ts | 12 +++ server/src/services/dayService.ts | 11 +++ server/src/services/queryHelpers.ts | 1 + server/src/types.ts | 2 + shared/src/assignment/assignment.schema.ts | 9 ++ shared/src/day/day.schema.ts | 2 + shared/src/i18n/ar/dayplan.ts | 2 + shared/src/i18n/br/dayplan.ts | 2 + shared/src/i18n/ca/dayplan.ts | 2 + shared/src/i18n/cs/dayplan.ts | 2 + shared/src/i18n/de/dayplan.ts | 2 + shared/src/i18n/en/dayplan.ts | 2 + shared/src/i18n/es/dayplan.ts | 2 + shared/src/i18n/fr/dayplan.ts | 2 + shared/src/i18n/gr/dayplan.ts | 2 + shared/src/i18n/hu/dayplan.ts | 2 + shared/src/i18n/id/dayplan.ts | 2 + shared/src/i18n/it/dayplan.ts | 2 + shared/src/i18n/ja/dayplan.ts | 2 + shared/src/i18n/ko/dayplan.ts | 2 + shared/src/i18n/nl/dayplan.ts | 2 + shared/src/i18n/pl/dayplan.ts | 2 + shared/src/i18n/ru/dayplan.ts | 2 + shared/src/i18n/sv/dayplan.ts | 2 + shared/src/i18n/tr/dayplan.ts | 2 + shared/src/i18n/uk/dayplan.ts | 2 + shared/src/i18n/vi/dayplan.ts | 2 + shared/src/i18n/zh-TW/dayplan.ts | 2 + shared/src/i18n/zh/dayplan.ts | 2 + 44 files changed, 348 insertions(+), 56 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 188da4106..c12c13131 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 = { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 2d0937dba..e9e7dca91 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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 => { + // 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 => { 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 = {} 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 (
{/* Toolbar */} @@ -1995,7 +2053,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP )}
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => )} - {routeLegs[day.id]?.[assignment.id] && } + {routeLegs[day.id]?.[assignment.id] && (canEditDays ? ( +
openLegModeMenu(e, assignment.id, day.id)} style={{ cursor: 'pointer' }}> + +
+ ) : ( + + ))} ) } @@ -2461,11 +2525,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{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 ( + :
{inner}
} // ── b4a) Plugin schedule contribution ("35 min charging at this stop") ─────── diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.ts index 95c681ed7..39fb9da5d 100644 --- a/client/src/mobile/screens/trip/plan/planTimelineModel.ts +++ b/client/src/mobile/screens/trip/plan/planTimelineModel.ts @@ -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 { 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 } diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index 4e379419e..fbb7254c8 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -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, } } diff --git a/client/src/types.ts b/client/src/types.ts index b695e729d..c9b689916 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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). */ diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts index 2309cea74..a8288a383 100644 --- a/client/tests/integration/hooks/useRouteCalculation.test.ts +++ b/client/tests/integration/hooks/useRouteCalculation.test.ts @@ -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 () => { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f01d904aa..13efe0f49 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/nest/assignments/assignments.controller.ts b/server/src/nest/assignments/assignments.controller.ts index 4a8f1b3e2..e21819c9b 100644 --- a/server/src/nest/assignments/assignments.controller.ts +++ b/server/src/nest/assignments/assignments.controller.ts @@ -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, diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 09ddbdc76..0f0343e11 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -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); } diff --git a/server/src/nest/days/days.controller.ts b/server/src/nest/days/days.controller.ts index 1a669039d..2b0fdefef 100644 --- a/server/src/nest/days/days.controller.ts +++ b/server/src/nest/days/days.controller.ts @@ -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, diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index b40fac7cc..2bdceb3eb 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -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); } diff --git a/server/src/services/assignmentService.ts b/server/src/services/assignmentService.ts index 2f2a89331..a3712bef4 100644 --- a/server/src/services/assignmentService.ts +++ b/server/src/services/assignmentService.ts @@ -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) { diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts index 315a70ece..3445d06ce 100644 --- a/server/src/services/dayService.ts +++ b/server/src/services/dayService.ts @@ -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); } diff --git a/server/src/services/queryHelpers.ts b/server/src/services/queryHelpers.ts index 1c6cdb9a4..f4f0781e2 100644 --- a/server/src/services/queryHelpers.ts +++ b/server/src/services/queryHelpers.ts @@ -95,6 +95,7 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[], 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: { diff --git a/server/src/types.ts b/server/src/types.ts index 2e6fc615b..8f30ceec4 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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; } diff --git a/shared/src/assignment/assignment.schema.ts b/shared/src/assignment/assignment.schema.ts index 57675a54c..86d54417d 100644 --- a/shared/src/assignment/assignment.schema.ts +++ b/shared/src/assignment/assignment.schema.ts @@ -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; +/** 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; + export const assignmentParticipantsRequestSchema = z.object({ user_ids: z.array(z.number()), }); diff --git a/shared/src/day/day.schema.ts b/shared/src/day/day.schema.ts index 317891111..f245220bf 100644 --- a/shared/src/day/day.schema.ts +++ b/shared/src/day/day.schema.ts @@ -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(), }); diff --git a/shared/src/i18n/ar/dayplan.ts b/shared/src/i18n/ar/dayplan.ts index c6a264128..f70aafd62 100644 --- a/shared/src/i18n/ar/dayplan.ts +++ b/shared/src/i18n/ar/dayplan.ts @@ -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': 'يلزم مكانان على الأقل لتحسين المسار', diff --git a/shared/src/i18n/br/dayplan.ts b/shared/src/i18n/br/dayplan.ts index e48a135fa..f3a9a5076 100644 --- a/shared/src/i18n/br/dayplan.ts +++ b/shared/src/i18n/br/dayplan.ts @@ -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', diff --git a/shared/src/i18n/ca/dayplan.ts b/shared/src/i18n/ca/dayplan.ts index b79649cc2..125b86e5a 100644 --- a/shared/src/i18n/ca/dayplan.ts +++ b/shared/src/i18n/ca/dayplan.ts @@ -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', diff --git a/shared/src/i18n/cs/dayplan.ts b/shared/src/i18n/cs/dayplan.ts index f0e799d21..3b49ff3b3 100644 --- a/shared/src/i18n/cs/dayplan.ts +++ b/shared/src/i18n/cs/dayplan.ts @@ -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', diff --git a/shared/src/i18n/de/dayplan.ts b/shared/src/i18n/de/dayplan.ts index 82f2710b3..7b03a23fe 100644 --- a/shared/src/i18n/de/dayplan.ts +++ b/shared/src/i18n/de/dayplan.ts @@ -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', diff --git a/shared/src/i18n/en/dayplan.ts b/shared/src/i18n/en/dayplan.ts index ee8879324..4ac63097b 100644 --- a/shared/src/i18n/en/dayplan.ts +++ b/shared/src/i18n/en/dayplan.ts @@ -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', diff --git a/shared/src/i18n/es/dayplan.ts b/shared/src/i18n/es/dayplan.ts index 8df7d8986..2dab3f1a9 100644 --- a/shared/src/i18n/es/dayplan.ts +++ b/shared/src/i18n/es/dayplan.ts @@ -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', diff --git a/shared/src/i18n/fr/dayplan.ts b/shared/src/i18n/fr/dayplan.ts index c243236d8..cabcbf992 100644 --- a/shared/src/i18n/fr/dayplan.ts +++ b/shared/src/i18n/fr/dayplan.ts @@ -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", diff --git a/shared/src/i18n/gr/dayplan.ts b/shared/src/i18n/gr/dayplan.ts index ca922f1ca..3f9cea669 100644 --- a/shared/src/i18n/gr/dayplan.ts +++ b/shared/src/i18n/gr/dayplan.ts @@ -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': 'Χρειάζονται τουλάχιστον δύο μέρη για βελτιστοποίηση διαδρομής', diff --git a/shared/src/i18n/hu/dayplan.ts b/shared/src/i18n/hu/dayplan.ts index 308035303..23c5c93bd 100644 --- a/shared/src/i18n/hu/dayplan.ts +++ b/shared/src/i18n/hu/dayplan.ts @@ -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', diff --git a/shared/src/i18n/id/dayplan.ts b/shared/src/i18n/id/dayplan.ts index 936c4f73c..669b16140 100644 --- a/shared/src/i18n/id/dayplan.ts +++ b/shared/src/i18n/id/dayplan.ts @@ -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', diff --git a/shared/src/i18n/it/dayplan.ts b/shared/src/i18n/it/dayplan.ts index 7bbadea57..7d7b064a5 100644 --- a/shared/src/i18n/it/dayplan.ts +++ b/shared/src/i18n/it/dayplan.ts @@ -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", diff --git a/shared/src/i18n/ja/dayplan.ts b/shared/src/i18n/ja/dayplan.ts index 1cb8d3f04..f0ef8fe7e 100644 --- a/shared/src/i18n/ja/dayplan.ts +++ b/shared/src/i18n/ja/dayplan.ts @@ -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つ以上の場所が必要です', diff --git a/shared/src/i18n/ko/dayplan.ts b/shared/src/i18n/ko/dayplan.ts index dea0c0d75..494c59579 100644 --- a/shared/src/i18n/ko/dayplan.ts +++ b/shared/src/i18n/ko/dayplan.ts @@ -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': '경로 최적화에는 최소 두 개의 장소가 필요합니다', diff --git a/shared/src/i18n/nl/dayplan.ts b/shared/src/i18n/nl/dayplan.ts index 4d31867e8..335cad114 100644 --- a/shared/src/i18n/nl/dayplan.ts +++ b/shared/src/i18n/nl/dayplan.ts @@ -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', diff --git a/shared/src/i18n/pl/dayplan.ts b/shared/src/i18n/pl/dayplan.ts index de7685e74..e8e430729 100644 --- a/shared/src/i18n/pl/dayplan.ts +++ b/shared/src/i18n/pl/dayplan.ts @@ -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ę', diff --git a/shared/src/i18n/ru/dayplan.ts b/shared/src/i18n/ru/dayplan.ts index 346945ade..bea158899 100644 --- a/shared/src/i18n/ru/dayplan.ts +++ b/shared/src/i18n/ru/dayplan.ts @@ -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': 'Для оптимизации маршрута нужно минимум два места', diff --git a/shared/src/i18n/sv/dayplan.ts b/shared/src/i18n/sv/dayplan.ts index 0022be9d6..22e84a0fd 100644 --- a/shared/src/i18n/sv/dayplan.ts +++ b/shared/src/i18n/sv/dayplan.ts @@ -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', diff --git a/shared/src/i18n/tr/dayplan.ts b/shared/src/i18n/tr/dayplan.ts index 332704386..b842f9114 100644 --- a/shared/src/i18n/tr/dayplan.ts +++ b/shared/src/i18n/tr/dayplan.ts @@ -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', diff --git a/shared/src/i18n/uk/dayplan.ts b/shared/src/i18n/uk/dayplan.ts index 50571c700..efd2c7f63 100644 --- a/shared/src/i18n/uk/dayplan.ts +++ b/shared/src/i18n/uk/dayplan.ts @@ -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': 'Для оптимизации маршрута нужно минимум два места', diff --git a/shared/src/i18n/vi/dayplan.ts b/shared/src/i18n/vi/dayplan.ts index 8cb0812d1..47b61888b 100644 --- a/shared/src/i18n/vi/dayplan.ts +++ b/shared/src/i18n/vi/dayplan.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/dayplan.ts b/shared/src/i18n/zh-TW/dayplan.ts index 3460c7162..fd8d93117 100644 --- a/shared/src/i18n/zh-TW/dayplan.ts +++ b/shared/src/i18n/zh-TW/dayplan.ts @@ -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': '路線最佳化至少需要兩個地點', diff --git a/shared/src/i18n/zh/dayplan.ts b/shared/src/i18n/zh/dayplan.ts index 6ca633553..a78290a2a 100644 --- a/shared/src/i18n/zh/dayplan.ts +++ b/shared/src/i18n/zh/dayplan.ts @@ -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': '路线优化至少需要两个地点',