diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 9f995c8dc..dc3e74f0b 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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 diff --git a/client/src/components/Planner/useLegModeActions.test.ts b/client/src/components/Planner/useLegModeActions.test.ts new file mode 100644 index 000000000..be18c6724 --- /dev/null +++ b/client/src/components/Planner/useLegModeActions.test.ts @@ -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'); + }); +}); diff --git a/client/src/components/Planner/useLegModeActions.ts b/client/src/components/Planner/useLegModeActions.ts new file mode 100644 index 000000000..22a8f45aa --- /dev/null +++ b/client/src/components/Planner/useLegModeActions.ts @@ -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; + +/** 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; +} + +/** + * 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 }; +} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index d5dec8ed4..34aa22a83 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -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. diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index fc11691db..3c4164494 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -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