mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(mobile): offer public transit in the day plan's leg menu, as on the desktop
The connector menu on the phone listed only the road profiles, so the transit search could not be opened for a single leg there. It now carries the Public transit entry whenever the trip has dates, seeded with the leg's two ends and the departure of the stop it leaves, and the transport sheet passes that departure on to the search. The prefill is built by one module the desktop day plan now uses as well. Closes #2398
This commit is contained in:
@@ -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<string, string>()
|
||||
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 }) }]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, Assignment[]>,
|
||||
accommodations: Accommodation[],
|
||||
reservations: Reservation[],
|
||||
): Map<string, string> {
|
||||
const m = new Map<string, string>()
|
||||
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<string, Assignment[]>,
|
||||
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<string, string>,
|
||||
assignments: Record<string, Assignment[]>,
|
||||
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 }
|
||||
}
|
||||
@@ -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 <ConnRow key={row.key} seg={row.seg} onTap={editing && row.assignmentId != null ? e => openLegMenu(e, row.assignmentId!) : undefined} />
|
||||
return <ConnRow key={row.key} seg={row.seg} onTap={editing && row.assignmentId != null ? e => openLegMenu(e, row.assignmentId!, row.seg) : undefined} />
|
||||
}
|
||||
})}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -704,6 +704,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
onAdd={(p) => saveTransport(p as Record<string, unknown> & { title: string })}
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
initialTime={transitPrefill?.time ?? null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
@@ -119,6 +119,8 @@ function buildTl(over: Record<string, unknown> = {}): 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' })
|
||||
|
||||
|
||||
@@ -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<string, unknown>) => void
|
||||
initialFrom: { name: string } | null
|
||||
initialTime: string | null
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="transit-day">{day.id}</span>
|
||||
<span data-testid="transit-places">{places.map(p => p.name).join(',')}</span>
|
||||
<span data-testid="transit-from">{initialFrom?.name ?? ''}</span>
|
||||
<span data-testid="transit-time">{initialTime ?? ''}</span>
|
||||
<button type="button" onClick={() => onAdd({ title: 'U4 to Prater', type: 'transit' })}>add-itinerary</button>
|
||||
</div>
|
||||
),
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<typeof import('../../../../src/api/client')>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user