diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index cba5e42fd..1cfbd786a 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -9,6 +9,7 @@ import { avatarSrc } from '../../utils/avatarSrc' import { safeHttpUrl } from '../../utils/safeUrl' import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap } from 'lucide-react' import { type PickedPlace } from './TransitSearchPanel' +import { buildTransitLeg, buildTransitNameIndex } from './transitLeg' import { assignmentsApi, reservationsApi, daysApi } from '../../api/client' import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator' import GoogleMapsIcon from '../shared/GoogleMapsIcon' @@ -1434,61 +1435,18 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP }) } - // A connector's endpoints carry only coordinates (RouteSegment.from/to, both - // [lat, lng]); resolve them back to the NAMES the transit search shows by indexing - // every located place, hotel and booking endpoint on the trip. Name is a property - // of a coordinate, so a trip-wide index is fine; a connector only renders when both - // ends are located, so a coordinate that misses the index is rare (nameless pick). - const transitNameIndex = useMemo(() => { - const m = new Map() - const put = (lat?: number | null, lng?: number | null, name?: string | null) => { - if (lat == null || lng == null || !name) return - const k = `${lat},${lng}` - if (!m.has(k)) m.set(k, name) - } - for (const list of Object.values(assignments)) { - for (const a of list) put(a.place?.lat, a.place?.lng, a.place?.name) - } - for (const acc of accommodations) put(acc.place_lat, acc.place_lng, acc.place_name) - for (const r of reservations) { - for (const ep of (r.endpoints || [])) put(ep.lat, ep.lng, ep.name) - } - return m - }, [assignments, accommodations, reservations]) - - // The leg's departure time must be resolved WITHIN its day: the same located POI can - // be revisited on another day at a different time (a supported pattern), so a - // trip-wide coordinate index would return the wrong day's time. Scope to this day's - // assignments (and the reservations that touch it) so a revisit keeps its own time. - const originDepartureTime = (originKey: string, dayId: number): string | null => { - for (const a of assignments[String(dayId)] ?? []) { - if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null - } - for (const r of reservations) { - if (r.day_id !== dayId && r.end_day_id !== dayId) continue - for (const ep of (r.endpoints || [])) { - if (ep.lat != null && ep.lng != null && `${ep.lat},${ep.lng}` === originKey) return ep.local_time ?? null - } - } - return null - } - - // Build the "public transport" prefill for a leg from its RouteSegment: MOTIS is - // driven off the coordinates, the names come from the index and the departure time - // from the origin's own day. Accepts 'H:mm' or 'HH:mm[:ss]', normalised to 'HH:mm'. - const buildTransitLeg = (seg: RouteSegment | undefined, dayId: number): { from: PickedPlace; to: PickedPlace; time: string | null } | null => { - if (!seg?.from || !seg?.to) return null - const pick = (c: [number, number]): PickedPlace => ({ name: transitNameIndex.get(`${c[0]},${c[1]}`) || '', lat: c[0], lng: c[1] }) - const rawTime = originDepartureTime(`${seg.from[0]},${seg.from[1]}`, dayId) - const m = rawTime ? /^(\d{1,2}):(\d{2})/.exec(rawTime) : null - return { from: pick(seg.from), to: pick(seg.to), time: m ? `${m[1].padStart(2, '0')}:${m[2]}` : null } - } + // Coordinates back to names for the transit search, over every located place, + // hotel and booking endpoint on the trip (see transitLeg.ts). + const transitNameIndex = useMemo( + () => buildTransitNameIndex(assignments, accommodations, reservations), + [assignments, accommodations, reservations], + ) // 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 leg = buildTransitLeg(seg, dayId) + 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 }) }] } diff --git a/client/src/components/Planner/transitLeg.test.ts b/client/src/components/Planner/transitLeg.test.ts new file mode 100644 index 000000000..4d1af2a96 --- /dev/null +++ b/client/src/components/Planner/transitLeg.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { buildTransitLeg, buildTransitNameIndex } from './transitLeg' +import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types' + +// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-006 + +const visit = (id: number, dayId: number, name: string, lat: number, lng: number, placeTime?: string) => + ({ id, day_id: dayId, place: { id, name, lat, lng, place_time: placeTime ?? null } }) as unknown as Assignment + +const seg = (from: [number, number], to: [number, number]) => ({ from, to }) as unknown as RouteSegment + +describe('buildTransitNameIndex', () => { + it('FE-PLANNER-TRANSITLEG-001: names every located place, hotel and booking endpoint by its coordinate', () => { + const names = buildTransitNameIndex( + { '1': [visit(11, 1, 'Louvre', 48.86, 2.34)] }, + [{ place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia' } as unknown as Accommodation], + [{ endpoints: [{ lat: 48.88, lng: 2.36, name: 'Gare du Nord' }] } as unknown as Reservation], + ) + expect(names.get('48.86,2.34')).toBe('Louvre') + expect(names.get('48.85,2.35')).toBe('Hotel Lutetia') + expect(names.get('48.88,2.36')).toBe('Gare du Nord') + }) + + it('FE-PLANNER-TRANSITLEG-002: keeps the first name for a coordinate and skips what has no position or name', () => { + const names = buildTransitNameIndex( + { '1': [visit(11, 1, 'First', 1, 2), visit(12, 1, 'Second', 1, 2)] }, + [{ place_lat: null, place_lng: null, place_name: 'Nowhere' } as unknown as Accommodation], + [{ endpoints: [{ lat: 3, lng: 4, name: '' }] } as unknown as Reservation, {} as Reservation], + ) + expect(names.get('1,2')).toBe('First') + expect(names.size).toBe(1) + }) +}) + +describe('buildTransitLeg', () => { + const assignments = { + '1': [visit(11, 1, 'Louvre', 48.86, 2.34, '9:05'), visit(12, 1, 'Orsay', 48.87, 2.33)], + '2': [visit(21, 2, 'Louvre', 48.86, 2.34, '15:30:00')], + } + const names = buildTransitNameIndex(assignments, [], []) + + it('FE-PLANNER-TRANSITLEG-003: seeds both ends and the origin\'s time on that day, as HH:mm', () => { + expect(buildTransitLeg(seg([48.86, 2.34], [48.87, 2.33]), 1, names, assignments, [])).toEqual({ + from: { name: 'Louvre', lat: 48.86, lng: 2.34 }, + to: { name: 'Orsay', lat: 48.87, lng: 2.33 }, + time: '09:05', + }) + // The same place on another day carries that day's time. + expect(buildTransitLeg(seg([48.86, 2.34], [48.87, 2.33]), 2, names, assignments, [])?.time).toBe('15:30') + }) + + it('FE-PLANNER-TRANSITLEG-004: takes the time from a booking endpoint that touches the day', () => { + const reservations = [ + { day_id: 9, end_day_id: null, endpoints: [{ lat: 1, lng: 1, local_time: '07:00' }] }, + { day_id: 3, end_day_id: 1, endpoints: [{ lat: 1, lng: 1, local_time: '18:45' }] }, + ] as unknown as Reservation[] + expect(buildTransitLeg(seg([1, 1], [2, 2]), 1, new Map(), {}, reservations)).toEqual({ + from: { name: '', lat: 1, lng: 1 }, + to: { name: '', lat: 2, lng: 2 }, + time: '18:45', + }) + }) + + it('FE-PLANNER-TRANSITLEG-005: leaves the time empty when the origin has none', () => { + expect(buildTransitLeg(seg([48.87, 2.33], [48.86, 2.34]), 1, names, assignments, [])?.time).toBeNull() + }) + + it('FE-PLANNER-TRANSITLEG-006: answers nothing for a leg without both ends', () => { + expect(buildTransitLeg(undefined, 1, names, assignments, [])).toBeNull() + expect(buildTransitLeg({ from: [1, 1] } as unknown as RouteSegment, 1, names, assignments, [])).toBeNull() + }) +}) diff --git a/client/src/components/Planner/transitLeg.ts b/client/src/components/Planner/transitLeg.ts new file mode 100644 index 000000000..f810948fc --- /dev/null +++ b/client/src/components/Planner/transitLeg.ts @@ -0,0 +1,81 @@ +import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types' +import type { PickedPlace } from './TransitSearchPanel' + +/** What the transit search is opened with for one leg of a day. */ +export interface TransitLeg { + from: PickedPlace + to: PickedPlace + time: string | null +} + +/** + * A connector's endpoints carry only coordinates (RouteSegment.from/to, both + * [lat, lng]); resolve them back to the NAMES the transit search shows by indexing + * every located place, hotel and booking endpoint on the trip. Name is a property + * of a coordinate, so a trip-wide index is fine; a connector only renders when both + * ends are located, so a coordinate that misses the index is rare (nameless pick). + */ +export function buildTransitNameIndex( + assignments: Record, + accommodations: Accommodation[], + reservations: Reservation[], +): Map { + const m = new Map() + const put = (lat?: number | null, lng?: number | null, name?: string | null) => { + if (lat == null || lng == null || !name) return + const k = `${lat},${lng}` + if (!m.has(k)) m.set(k, name) + } + for (const list of Object.values(assignments)) { + for (const a of list) put(a.place?.lat, a.place?.lng, a.place?.name) + } + for (const acc of accommodations) put(acc.place_lat, acc.place_lng, acc.place_name) + for (const r of reservations) { + for (const ep of (r.endpoints || [])) put(ep.lat, ep.lng, ep.name) + } + return m +} + +/** + * The leg's departure time must be resolved WITHIN its day: the same located POI can + * be revisited on another day at a different time (a supported pattern), so a + * trip-wide coordinate index would return the wrong day's time. Scope to this day's + * assignments (and the reservations that touch it) so a revisit keeps its own time. + */ +function originDepartureTime( + originKey: string, + dayId: number, + assignments: Record, + reservations: Reservation[], +): string | null { + for (const a of assignments[String(dayId)] ?? []) { + if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null + } + for (const r of reservations) { + if (r.day_id !== dayId && r.end_day_id !== dayId) continue + for (const ep of (r.endpoints || [])) { + if (ep.lat != null && ep.lng != null && `${ep.lat},${ep.lng}` === originKey) return ep.local_time ?? null + } + } + return null +} + +/** + * The public transport prefill for a leg, from its RouteSegment: MOTIS is driven off + * the coordinates, the names come from the index and the departure time from the + * origin's own day. Accepts 'H:mm' or 'HH:mm[:ss]', normalised to 'HH:mm'. Shared by + * the desktop day plan and the phone timeline, so both open the search alike. + */ +export function buildTransitLeg( + seg: RouteSegment | undefined, + dayId: number, + names: Map, + assignments: Record, + reservations: Reservation[], +): TransitLeg | null { + if (!seg?.from || !seg?.to) return null + const pick = (c: [number, number]): PickedPlace => ({ name: names.get(`${c[0]},${c[1]}`) || '', lat: c[0], lng: c[1] }) + const rawTime = originDepartureTime(`${seg.from[0]},${seg.from[1]}`, dayId, assignments, reservations) + const m = rawTime ? /^(\d{1,2}):(\d{2})/.exec(rawTime) : null + return { from: pick(seg.from), to: pick(seg.to), time: m ? `${m[1].padStart(2, '0')}:${m[2]}` : null } +} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index b72c29f70..2baed3029 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -2,7 +2,7 @@ import { useRef, useState, type MouseEvent } from 'react' import { ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut, MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2, - Car, Footprints, Zap, RotateCcw, + Car, Footprints, Zap, RotateCcw, TramFront, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { useContextMenu, ContextMenu } from '../../../../components/shared/ContextMenu' @@ -22,6 +22,7 @@ import { Fragment } from 'react' import MDancingTrek from '../../../components/MDancingTrek' import type { MPlanTimelineProps } from '../MTripShell' import type { MergedItem } from '../../../../utils/dayMerge' +import type { RouteSegment } from '../../../../types' import type { Assignment } from '../../../../types' import type { ComponentType, ReactNode } from 'react' import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon' @@ -45,9 +46,12 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) { // Per-segment travel mode (#1281): tap a connector → pick the leg's mode. const legMenu = useContextMenu() const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car) - const openLegMenu = (e: MouseEvent, assignmentId: number) => { + 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: modeIcon(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) }, ]) @@ -250,7 +254,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) { /> ) case 'conn': - return openLegMenu(e, row.assignmentId!) : undefined} /> + return openLegMenu(e, row.assignmentId!, row.seg) : undefined} /> } })} diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index ef1e84442..4bc28ea88 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -6,13 +6,14 @@ import { usePluginStore } from '../../../../store/pluginStore' import { getDayBookendHotels } from '../../../../utils/dayOrder' import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay } from '../../../../utils/dayMerge' import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute' +import { buildTransitLeg, buildTransitNameIndex, type TransitLeg } from '../../../../components/Planner/transitLeg' import { buildPlanRows, breaksChronology, findUpNext, hotelChipsForDay, hotelLegsForDay, itemHasTime, type HotelLegs, type PlanRow, type TransportEntry, } from './planTimelineModel' import type { TripPlanner } from '../MTripShell' import type { WeatherResult } from '@trek/shared' -import type { Assignment, Place } from '../../../../types' +import type { Assignment, Place, RouteSegment } from '../../../../types' import type { MergedItem } from '../../../../utils/dayMerge' /** @@ -376,6 +377,29 @@ export function useMPlanTimeline(planner: TripPlanner) { }) }, [day, tripId, toast, t, tripActions]) + // ── Public transit for one leg (#2398) ── + // The entry the desktop connector menu carries: the automated search, seeded with + // the leg's two ends and the time the stop it leaves is planned for. It needs the + // trip's dates, as on the desktop and as the day sheet's own transit button does. + const tripHasDates = Boolean(planner.trip?.start_date && planner.trip?.end_date) + const transitNames = useMemo( + () => buildTransitNameIndex(assignments, tripAccommodations, reservations), + [assignments, tripAccommodations, reservations], + ) + const transitLegFor = useCallback((seg: RouteSegment): TransitLeg | null => { + if (!day || !tripHasDates) return null + return buildTransitLeg(seg, day.id, transitNames, assignments, reservations) + }, [day, tripHasDates, transitNames, assignments, reservations]) + + const planTransitLeg = useCallback((leg: TransitLeg) => { + if (!day) return + planner.setTransportModalDayId(day.id) + planner.setEditingTransport(null) + planner.setTransitPrefill(leg) + planner.setTransportModalAutomated(true) + planner.setShowTransportModal(true) + }, [planner, day]) + return { day, rows, hotelLegs, merged, hotelChips, weather, weatherTemp, upNext, weatherPlaceName: weatherAnchor?.name ?? null, @@ -385,7 +409,7 @@ export function useMPlanTimeline(planner: TripPlanner) { moveRowTo, addPlace, addBooking, addTransport, optimize, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf, - routeModeOptions, setLegMode, + routeModeOptions, setLegMode, transitLegFor, planTransitLeg, } } diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index 01daa1878..9d41434d2 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -704,6 +704,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo onAdd={(p) => saveTransport(p as Record & { title: string })} initialFrom={transitPrefill?.from ?? null} initialTo={transitPrefill?.to ?? null} + initialTime={transitPrefill?.time ?? null} /> ) diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx index 39446373e..f458b9fbe 100644 --- a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx +++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx @@ -9,7 +9,7 @@ import type { MergedItem } from '../../../../src/utils/dayMerge' import type { Assignment, Day, DayNote, Place, RouteSegment } from '../../../../src/types' import MPlanTimeline from '../../../../src/mobile/screens/trip/plan/MPlanTimeline' -// FE-MOB-PLTL-001 to FE-MOB-PLTL-045 +// FE-MOB-PLTL-001 to FE-MOB-PLTL-047 const mocks = vi.hoisted(() => ({ tl: {} as Record, @@ -119,6 +119,8 @@ function buildTl(over: Record = {}): MPlanTimelineController { { key: 'plugin:ev/fastest', label: 'EV fastest' }, ], setLegMode: vi.fn(), + transitLegFor: vi.fn(() => null), + planTransitLeg: vi.fn(), ...over, } as unknown as MPlanTimelineController } @@ -388,6 +390,26 @@ describe('MPlanTimeline', () => { expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, null) }) + it('FE-MOB-PLTL-046: a leg with a transit search behind it offers Public transit and opens it (#2398)', () => { + const leg = { from: { name: 'Museum', lat: 35.71, lng: 139.79 }, to: { name: 'Ueno Park', lat: 35.72, lng: 139.77 }, time: '09:30' } + renderTimeline({ transitLegFor: vi.fn(() => leg) }, {}, { mode: 'edit' }) + fireEvent.click(connector()) + + expect(mocks.tl.transitLegFor).toHaveBeenCalledWith(SEG) + fireEvent.click(screen.getByText('transit.title')) + + expect(mocks.tl.planTransitLeg).toHaveBeenCalledWith(leg) + expect(mocks.tl.setLegMode).not.toHaveBeenCalled() + }) + + it('FE-MOB-PLTL-047: without a transit search for the leg the menu keeps to the road profiles', () => { + renderEditing() + fireEvent.click(connector()) + + expect(screen.getByText('Driving')).toBeInTheDocument() + expect(screen.queryByText('transit.title')).not.toBeInTheDocument() + }) + it('FE-MOB-PLTL-020: read-only members get no tappable connectors', () => { renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' }) diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx index dec2b9b68..3d43b062e 100644 --- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx @@ -73,16 +73,18 @@ vi.mock('../../../../src/components/Planner/LocationSelect', () => ({ })) vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({ - default: ({ day, places, onAdd, initialFrom }: { + default: ({ day, places, onAdd, initialFrom, initialTime }: { day: { id: number } places: { name: string }[] onAdd: (p: Record) => void initialFrom: { name: string } | null + initialTime: string | null }) => (
{day.id} {places.map(p => p.name).join(',')} {initialFrom?.name ?? ''} + {initialTime ?? ''}
), @@ -921,7 +923,7 @@ describe('MTransportFormSheet', () => { const planner = makePlanner({ transportModalAutomated: true, transportModalDayId: 12, - transitPrefill: { from: { name: 'Hotel', lat: 34.7, lng: 135.5 }, to: null }, + transitPrefill: { from: { name: 'Hotel', lat: 34.7, lng: 135.5 }, to: null, time: '08:15' }, places: [{ id: 1, name: 'Fushimi Inari' }, { id: 2, name: 'Nishiki Market' }], assignments: { '12': [{ id: 9, place_id: 2, order_index: 1 }, { id: 8, place_id: 1, order_index: 0 }] }, }) @@ -929,6 +931,8 @@ describe('MTransportFormSheet', () => { expect(screen.getByTestId('transit-day')).toHaveTextContent('12') expect(screen.getByTestId('transit-places')).toHaveTextContent('Fushimi Inari,Nishiki Market') expect(screen.getByTestId('transit-from')).toHaveTextContent('Hotel') + // A leg picked from the timeline's connector menu carries its departure (#2398). + expect(screen.getByTestId('transit-time')).toHaveTextContent('08:15') }) it('FE-MOB-TRFRM-040: the transit panel saves its itinerary through the shared save path', async () => { diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx b/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx new file mode 100644 index 000000000..124f252ed --- /dev/null +++ b/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '../../../helpers/render' +import { buildPlanner } from '../../../helpers/mobileTrip' +import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../helpers/factories' +import { useMPlanTimeline } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline' +import type { RouteSegment } from '../../../../src/types' + +// FE-MOB-PLTL-HOOK-001 to FE-MOB-PLTL-HOOK-002: public transit for one leg (#2398) + +vi.mock('../../../../src/hooks/useRouteCalculation', () => ({ + useRouteCalculation: () => ({ routeSegments: [] }), +})) + +vi.mock('../../../../src/api/client', async importOriginal => { + const actual = await importOriginal() + return { ...actual, weatherApi: { ...actual.weatherApi, get: vi.fn(() => new Promise(() => {})) } } +}) + +const DAY = buildDay({ id: 5, trip_id: 1, day_number: 1, date: '2026-05-02' }) +const assignments = { + '5': [ + buildAssignment({ id: 11, day_id: 5, order_index: 0, place: buildPlace({ id: 1, name: 'Museum', place_time: '09:30', lat: 35.71, lng: 139.79 }) }), + buildAssignment({ id: 12, day_id: 5, order_index: 1, place: buildPlace({ id: 2, name: 'Ueno Park', lat: 35.72, lng: 139.77 }) }), + ], +} +const SEG = { from: [35.71, 139.79], to: [35.72, 139.77] } as unknown as RouteSegment + +function planner(trip = buildTrip({ id: 1, start_date: '2026-05-01', end_date: '2026-05-05' })) { + return buildPlanner({ trip, days: [DAY], selectedDayId: DAY.id, assignments } as never) +} + +describe('useMPlanTimeline, public transit for one leg', () => { + it('FE-MOB-PLTL-HOOK-001: seeds the automated search with the leg and opens it for the day', () => { + const p = planner() + const { result } = renderHook(() => useMPlanTimeline(p)) + + const leg = result.current.transitLegFor(SEG) + expect(leg).toEqual({ + from: { name: 'Museum', lat: 35.71, lng: 139.79 }, + to: { name: 'Ueno Park', lat: 35.72, lng: 139.77 }, + time: '09:30', + }) + + act(() => result.current.planTransitLeg(leg!)) + + expect(p.setTransportModalDayId).toHaveBeenCalledWith(5) + expect(p.setEditingTransport).toHaveBeenCalledWith(null) + expect(p.setTransitPrefill).toHaveBeenCalledWith(leg) + expect(p.setTransportModalAutomated).toHaveBeenCalledWith(true) + expect(p.setShowTransportModal).toHaveBeenCalledWith(true) + }) + + it('FE-MOB-PLTL-HOOK-002: offers nothing on a trip without dates, like the desktop and the day sheet', () => { + const p = planner(buildTrip({ id: 1, start_date: null, end_date: null })) + const { result } = renderHook(() => useMPlanTimeline(p)) + + expect(result.current.transitLegFor(SEG)).toBeNull() + }) +})