refactor(planner): share the per leg travel mode between desktop and phone

The day plan's connector menu and the phone timeline's are built by
legModeMenuItems, and both save a leg's mode through useLegModeActions, which
reports a refused write and reloads the days the same way on both.
This commit is contained in:
Maurice
2026-10-08 21:07:11 +00:00
parent 20d7e62afc
commit 38c7d76c5d
5 changed files with 188 additions and 42 deletions
@@ -10,7 +10,7 @@ import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
import GoogleMapsIcon from '../shared/GoogleMapsIcon'
import PlaceAvatar from '../shared/PlaceAvatar'
@@ -62,6 +62,7 @@ import { findTodayDayId } from './today'
import { markdownLinkComponents } from '../shared/markdownLink'
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
import { resolveLegMode } from './legMode'
import { useLegModeActions } from './useLegModeActions'
import { dayExportStops, fillAroundLocked } from './dayRoute'
import { projectDayItinerary } from '../Map/dayTourProjection'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
@@ -1515,7 +1516,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const gripsShown = canEditDays && !dragDisabled
// ── Per-segment / per-day travel mode (#1281) ──────────────────────────────
const modeIcon = routeModeIcon
const { legModeMenu, persistLegMode } = useLegModeActions({ tripId, toast, t, tripActions })
// 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
@@ -1529,10 +1530,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
[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'))
void tripActions.refreshDays(tripId)
})
persistLegMode(assignmentId, mode)
}
// The whole-day default (the Car/Foot picker). Persisted so it survives a reload,
@@ -1556,22 +1554,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// The extra connector-menu entry (#1281 follow-up): search public transit for this
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
const transitLegMenuItem = (dayId: number, seg?: RouteSegment) => {
if (!onPlanTransitLeg) return []
const transitLegPlanner = (dayId: number, seg?: RouteSegment) => {
if (!onPlanTransitLeg) return undefined
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
if (!leg) return []
return [{ label: t('transit.title'), icon: TramFront, onClick: () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time }) }]
if (!leg) return undefined
return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time })
}
// Open the mode menu at the clicked connector: every route profile, the optional
// "public transport" entry, plus a "use day default" entry that clears the override.
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
ctxMenu.open(e, [
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setLegMode(assignmentId, dayId, o.key) })),
...transitLegMenuItem(dayId, seg),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setLegMode(assignmentId, dayId, null) },
])
ctxMenu.open(e, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
}
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
@@ -1584,19 +1577,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
[key]: assignments[key].map(a => (a.id === assignmentId ? { ...a, incoming_leg_transport_mode: mode } : a)),
})
}
assignmentsApi.updateTransport(tripId, assignmentId, mode, 'incoming').catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
void tripActions.refreshDays(tripId)
})
persistLegMode(assignmentId, mode, 'incoming')
}
const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
ctxMenu.open(e, [
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setIncomingLegMode(assignmentId, dayId, o.key) })),
...transitLegMenuItem(dayId, seg),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setIncomingLegMode(assignmentId, dayId, null) },
])
ctxMenu.open(e, legModeMenu(mode => setIncomingLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
}
// Enter/Space on a route connector opens the same mode menu a click opens. A key
@@ -0,0 +1,94 @@
// FE-PLANNER-LEGMODE-001 to -006: the per-leg travel mode behind the desktop day
// plan's connector menu and the phone timeline's.
import { act, renderHook, waitFor } from '@testing-library/react';
import { RotateCcw, TramFront } from 'lucide-react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { assignmentsApi } from '../../api/client';
import { routeModeIcon } from './routeModes';
import { legModeMenuItems, useLegModeActions } from './useLegModeActions';
const t = (key: string) => `t:${key}`;
const OPTIONS = [
{ key: 'driving', label: 'Car' },
{ key: 'walking', label: 'Foot' },
];
function deps() {
return { tripId: 3, toast: { error: vi.fn() }, t, tripActions: { refreshDays: vi.fn(async () => undefined) } };
}
beforeEach(() => {
vi.spyOn(assignmentsApi, 'updateTransport').mockResolvedValue({});
});
afterEach(() => vi.restoreAllMocks());
describe('legModeMenuItems', () => {
it('FE-PLANNER-LEGMODE-001: every profile, then a divider and the day default that clears the leg', () => {
const pick = vi.fn();
const items = legModeMenuItems(OPTIONS, pick, t);
expect(items.map((i) => i.label ?? (i.divider ? '---' : ''))).toEqual([
'Car',
'Foot',
'---',
't:dayplan.transportMode.useDefault',
]);
expect(items[1].icon).toBe(routeModeIcon('walking'));
expect(items[3].icon).toBe(RotateCcw);
items[1].onClick?.();
items[3].onClick?.();
expect(pick.mock.calls).toEqual([['walking'], [null]]);
});
it('FE-PLANNER-LEGMODE-002: public transit sits under the profiles only when the leg can be searched', () => {
const planTransit = vi.fn();
const items = legModeMenuItems(OPTIONS, vi.fn(), t, planTransit);
expect(items[2]).toEqual({ label: 't:transit.title', icon: TramFront, onClick: planTransit });
expect(legModeMenuItems(OPTIONS, vi.fn(), t).some((i) => i.label === 't:transit.title')).toBe(false);
});
});
describe('useLegModeActions', () => {
it('FE-PLANNER-LEGMODE-003: saves the leg leaving a stop, and with incoming the one entering it', async () => {
const d = deps();
const { result } = renderHook(() => useLegModeActions(d));
await act(async () => {
result.current.persistLegMode(11, 'walking');
result.current.persistLegMode(12, null, 'incoming');
});
expect(vi.mocked(assignmentsApi.updateTransport).mock.calls).toEqual([
[3, 11, 'walking'],
[3, 12, null, 'incoming'],
]);
expect(d.toast.error).not.toHaveBeenCalled();
expect(d.tripActions.refreshDays).not.toHaveBeenCalled();
});
it('FE-PLANNER-LEGMODE-004: a refused write says why and reloads the days', async () => {
vi.mocked(assignmentsApi.updateTransport).mockRejectedValueOnce(new Error('conflict'));
const d = deps();
const { result } = renderHook(() => useLegModeActions(d));
act(() => result.current.persistLegMode(11, 'cycling'));
await waitFor(() => expect(d.toast.error).toHaveBeenCalledWith('conflict'));
expect(d.tripActions.refreshDays).toHaveBeenCalledWith(3);
});
it('FE-PLANNER-LEGMODE-005: a failure without a message falls back to the generic line', async () => {
vi.mocked(assignmentsApi.updateTransport).mockRejectedValueOnce('boom');
const d = deps();
const { result } = renderHook(() => useLegModeActions(d));
act(() => result.current.persistLegMode(11, null, 'incoming'));
await waitFor(() => expect(d.toast.error).toHaveBeenCalledWith('t:common.unknownError'));
});
it('FE-PLANNER-LEGMODE-006: the menu offers the route profiles the planner knows', () => {
const { result } = renderHook(() => useLegModeActions(deps()));
const keys = result.current.routeModeOptions.map((o) => o.key);
expect(keys).toEqual(['driving', 'walking', 'cycling']);
const pick = vi.fn();
const items = result.current.legModeMenu(pick);
expect(items).toHaveLength(keys.length + 2);
items[2].onClick?.();
expect(pick).toHaveBeenCalledWith('cycling');
});
});
@@ -0,0 +1,70 @@
import { type LucideIcon, RotateCcw, TramFront } from 'lucide-react';
import { useCallback } from 'react';
import { assignmentsApi } from '../../api/client';
import type { TripStoreState } from '../../store/tripStore';
import { type RouteModeOption, routeModeIcon, useRouteModeOptions } from './routeModes';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** One entry of the connector menu, in the shape the context menu draws. */
export interface LegModeMenuItem {
label?: string;
icon?: LucideIcon;
onClick?: () => void;
divider?: boolean;
}
/**
* The menu a leg's connector opens (#1281): every route profile, public transit
* for the leg when it can be searched (#2398), and the day default, which clears
* the leg's own mode again.
*/
export function legModeMenuItems(
options: RouteModeOption[],
pick: (mode: string | null) => void,
t: Translate,
planTransit?: () => void
): LegModeMenuItem[] {
return [
...options.map((o) => ({ label: o.label, icon: routeModeIcon(o.key), onClick: () => pick(o.key) })),
...(planTransit ? [{ label: t('transit.title'), icon: TramFront, onClick: planTransit }] : []),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => pick(null) },
];
}
export interface LegModeDeps {
tripId: number;
toast: { error: (message: string) => void };
t: Translate;
tripActions: Pick<TripStoreState, 'refreshDays'>;
}
/**
* The per-leg travel mode behind both the desktop day plan and the phone timeline.
* Each side shows a new mode at once its own way; `persistLegMode` then saves it for
* the leg leaving a stop, or with 'incoming' for the one entering it. A refused write
* says why and reloads the days, so the shown mode goes back.
*/
export function useLegModeActions({ tripId, toast, t, tripActions }: LegModeDeps) {
const routeModeOptions = useRouteModeOptions();
const persistLegMode = useCallback(
(assignmentId: number, mode: string | null, direction?: 'incoming') => {
const write = direction
? assignmentsApi.updateTransport(tripId, assignmentId, mode, direction)
: assignmentsApi.updateTransport(tripId, assignmentId, mode);
write.catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : t('common.unknownError'));
void tripActions.refreshDays(tripId);
});
},
[tripId, toast, t, tripActions]
);
const legModeMenu = (pick: (mode: string | null) => void, planTransit?: () => void) =>
legModeMenuItems(routeModeOptions, pick, t, planTransit);
return { routeModeOptions, legModeMenu, persistLegMode };
}
@@ -1,10 +1,10 @@
import { useRef, useState, type MouseEvent } from 'react'
import { ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut, MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2, RotateCcw, TramFront } from 'lucide-react'
import { ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut, MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useContextMenu, ContextMenu } from '../../../../components/shared/ContextMenu'
import MarkdownText from '../../../../components/shared/MarkdownText'
import { fmtTransitDuration } from '../../../../components/Planner/transitDisplay'
import { routeModeIcon } from '../../../../components/Planner/routeModes'
import { legModeMenuItems } from '../../../../components/Planner/useLegModeActions'
import { formatTime } from '../../../../utils/formatters'
import { useMPlanTimeline, type MPlanTimelineController } from './useMPlanTimeline'
import { cityPillsForDay } from './planTimelineModel'
@@ -47,12 +47,12 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
const openLegMenu = (e: MouseEvent, assignmentId: number, seg: RouteSegment) => {
// Public transit sits under the road profiles, as on the desktop (#2398).
const transitLeg = tl.transitLegFor(seg)
legMenu.open(e, [
...tl.routeModeOptions.map(o => ({ label: o.label, icon: routeModeIcon(o.key), onClick: () => tl.setLegMode(assignmentId, o.key) })),
...(transitLeg ? [{ label: t('transit.title'), icon: TramFront, onClick: () => tl.planTransitLeg(transitLeg) }] : []),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => tl.setLegMode(assignmentId, null) },
])
legMenu.open(e, legModeMenuItems(
tl.routeModeOptions,
mode => tl.setLegMode(assignmentId, mode),
t,
transitLeg ? () => tl.planTransitLeg(transitLeg) : undefined,
))
}
// Plugin time contributions in the day plan (dayScheduleProvider hook) —
// slotted under their anchor rows, same as the desktop sidebar.
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTripStore } from '../../../../store/tripStore'
import { useRouteCalculation } from '../../../../hooks/useRouteCalculation'
import { assignmentsApi, reservationsApi, weatherApi } from '../../../../api/client'
import { useRouteModeOptions } from '../../../../components/Planner/routeModes'
import { reservationsApi, weatherApi } from '../../../../api/client'
import { useLegModeActions } from '../../../../components/Planner/useLegModeActions'
import { getDayBookendHotels } from '../../../../utils/dayOrder'
import { dayWeatherAnchor } from '../../../../utils/dayWeather'
import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder'
@@ -327,7 +327,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
: null
// ── Per-segment travel mode (#1281) ──
const routeModeOptions = useRouteModeOptions()
const { routeModeOptions, persistLegMode } = useLegModeActions({ tripId, toast, t, tripActions })
// 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.
@@ -337,11 +337,8 @@ export function useMPlanTimeline(planner: TripPlanner) {
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])
persistLegMode(assignmentId, mode)
}, [day, persistLegMode])
// ── Public transit for one leg (#2398) ──
// The entry the desktop connector menu carries: the automated search, seeded with