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': '路线优化至少需要两个地点',