mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(tours): public transit between two city walks in the day plan, from the first walk's end at the time it ends (CW-08)
"Public transit" on the connector between two city walks in the day plan, on the desktop and on the phone, now opens the existing search from where the first walk really ends to where the next one starts, at the coordinates the day's route draws, named after the first walk's last stop and the next walk's first stop. A loop ends at its first stop again. A stop without a name takes the name of the trip place it stands for, and a walk whose stops cannot be read on this device names that end "End of" or "Start of" the walk. The search opens on the walks' day and departs when the first walk ends: its start that day, the time on the day's entry before the place's own, plus its planned total, a total entered by hand before walking and stays. The assignment's own end time is not used. Above the search a note says when the first walk ends, "at the earliest" where a stay or the walking time is not known yet, and when the next walk starts. Without a start or a total no time is made up: the time field stays empty and Search waits until one is chosen. A walk that only ends after midnight says so, and the time field stays empty as well. On any other day, or for any other leg, the search opens as before, with 09:00 as its default. A connection that arrives after the next walk starts says so in its card, before its legs are opened and before it is taken. Nothing is saved until "Add to day", which keeps the connection as its own journey in the plan as before; both walks stay two walks on foot and nothing is moved. A journey saved before a walk that now arrives after the walk starts says so on its row in the plan, on the desktop and the phone, while the plan is looked at. Nothing is rewritten. The desktop no longer draws a connector of nothing between a stop and a booking at the same spot, as the phone already did, so a journey saved at the walks' ends adds no walk on either side. A leg leaving a loop tour no longer departs at the tour's start time. The leg is told by the walk it leaves, so two walks of a day that end at one spot, such as two loops from the hotel, each search from their own end and time, and a stop between two walks keeps the leg an ordinary one. Only the leg asked for last opens the search, and none opens once the plan is left while the walks' stops are still being read. Both views run on one shared hook, the walk's stops are read through one helper the stop list uses as well, and the walk's start on a day is worked out in one place for the times shown in the detail and here.
This commit is contained in:
@@ -1,16 +1,28 @@
|
||||
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
||||
// and that it falls back to the plain Place row when it is off. The mocks are the
|
||||
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||
// CW-08: public transit between two city walks from the day plan's connector.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import {
|
||||
buildAssignment,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildReservation,
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Reservation } from '../../types';
|
||||
import { tourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import DayPlanSidebar from './DayPlanSidebar';
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
@@ -56,12 +68,14 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
||||
// One leg per waypoint gap, carrying its two ends as the real one does; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints: { lat: number; lng: number }[] = []) =>
|
||||
Promise.resolve({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
legs: waypoints.slice(1).map((to, index) => ({
|
||||
from: [waypoints[index]?.lat, waypoints[index]?.lng],
|
||||
to: [to.lat, to.lng],
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
drivingText: '10 min',
|
||||
@@ -71,6 +85,13 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// CW-08: a walk's stops are read for the names of a transit leg's two ends.
|
||||
const mockTourDetail = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: mockTourDetail } };
|
||||
});
|
||||
|
||||
// PlaceAvatar needs IntersectionObserver
|
||||
class MockIO {
|
||||
observe = vi.fn();
|
||||
@@ -328,3 +349,214 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// CW-08: the old town walk ends at the bridge, the harbour walk starts at the fish market.
|
||||
const TOURS_ON = [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }];
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
tour_place_id: 51,
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
tour_place_id: 52,
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const cafe = buildPlace({ id: 53, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
function walkTour(place: { id: number; name: string }, overrides: Partial<TourListItem> = {}): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
distance: 3,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: 90,
|
||||
break_additional_minutes: null,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: true,
|
||||
caution: false,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
...overrides,
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean; onPlanTransitLeg?: () => void }) {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
}),
|
||||
buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
}),
|
||||
...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []),
|
||||
],
|
||||
};
|
||||
const tours = [walkTour(oldTown), walkTour(harbour)];
|
||||
return makeDefaultProps({
|
||||
days: [day],
|
||||
places: [oldTown, harbour, cafe],
|
||||
assignments,
|
||||
selectedDayId: 10,
|
||||
routeShown: true,
|
||||
tourPlaceIds: new Set([51, 52]),
|
||||
plannedTourDurations: new Map(tours.map((tour) => [tour.place_id, { ...tourPlannedTimes(tour), tour }])),
|
||||
reservations: extra.reservations ?? [],
|
||||
onPlanTransitLeg: extra.onPlanTransitLeg,
|
||||
});
|
||||
}
|
||||
|
||||
/** A journey saved from the old town walk's end to the harbour walk's start, arriving at `arrives`. */
|
||||
function savedJourney(arrives: string): Reservation {
|
||||
return {
|
||||
...buildReservation({
|
||||
id: 300,
|
||||
type: 'transit',
|
||||
title: 'Old bridge → Fish market',
|
||||
reservation_time: '2025-06-01T12:20',
|
||||
reservation_end_time: `2025-06-01T${arrives}`,
|
||||
day_id: 10,
|
||||
}),
|
||||
endpoints: [
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: 'Old bridge',
|
||||
lat: 52.51,
|
||||
lng: 13.42,
|
||||
local_date: '2025-06-01',
|
||||
local_time: '12:20',
|
||||
},
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: 'Fish market',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
local_date: '2025-06-01',
|
||||
local_time: arrives,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
legs: [{ mode: 'TRAM', line: 'M1', duration: 1500 }],
|
||||
},
|
||||
},
|
||||
} as unknown as Reservation;
|
||||
}
|
||||
|
||||
function contextMenu() {
|
||||
return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement);
|
||||
}
|
||||
|
||||
describe('DayPlanSidebar, public transit between two city walks (CW-08)', () => {
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: TOURS_ON });
|
||||
mockTourDetail.mockImplementation((_tripId: number, placeId: number) =>
|
||||
Promise.resolve({
|
||||
tour: walkTour(placeId === 51 ? oldTown : harbour),
|
||||
waypoints:
|
||||
placeId === 51
|
||||
? [
|
||||
{ lat: 52.52, lng: 13.4, role: 'start', sequence: 0, key: 'a1', name: 'Town hall', stay_minutes: 30 },
|
||||
{ lat: 52.51, lng: 13.42, role: 'end', sequence: 1, key: 'a2', name: 'Old bridge', stay_minutes: 15 },
|
||||
]
|
||||
: [
|
||||
{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0, key: 'b1', name: 'Fish market' },
|
||||
{ lat: 52.49, lng: 13.46, role: 'end', sequence: 1, key: 'b2', name: 'Lighthouse' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('CW-08: the connector between two walks opens the search from the last stop to the first, when the first walk ends', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg })} />);
|
||||
|
||||
await user.click(await screen.findByLabelText('Change travel mode'));
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
// The walk's end at 10:00 plus 90 min walking and 45 min of stays; nothing is saved.
|
||||
await waitFor(() =>
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mockTourDetail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('CW-08: a leg from a walk to a place keeps the search as for any leg, and reads no stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, withCafe: true })} />);
|
||||
|
||||
const connectors = await screen.findAllByLabelText('Change travel mode');
|
||||
expect(connectors).toHaveLength(2);
|
||||
await user.click(connectors[1]);
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: '', lat: 52.49, lng: 13.46 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: null,
|
||||
});
|
||||
expect(mockTourDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CW-08: a journey saved at the walks’ ends adds no walk of nothing on either side of it', async () => {
|
||||
render(<DayPlanSidebar {...walkDayProps({ withCafe: true, reservations: [savedJourney('12:50')] })} />);
|
||||
|
||||
// The only leg left is the one from the harbour walk to the cafe; the journey covers the rest.
|
||||
await screen.findByLabelText('Change travel mode');
|
||||
expect(screen.getAllByLabelText('Change travel mode')).toHaveLength(1);
|
||||
expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CW-08: a saved journey that now arrives after the next walk starts says so, and changes nothing', () => {
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, reservations: [savedJourney('13:10')] })} />);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(onPlanTransitLeg).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallba
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
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 type { PlannedTransitLeg } from './transitLeg'
|
||||
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
|
||||
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
|
||||
@@ -161,8 +162,9 @@ export interface DayPlanSidebarProps {
|
||||
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
|
||||
* the leg's origin and destination endpoints plus the origin's departure time,
|
||||
* so "public transport" becomes an option on any connector, not just the day header.
|
||||
* Between two city walks it carries what the search says of them (CW-08).
|
||||
*/
|
||||
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
|
||||
onPlanTransitLeg?: (leg: PlannedTransitLeg) => void
|
||||
/** Opens the journey view for a saved transit entry (#1065). */
|
||||
onOpenTransit?: (reservation: Reservation) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
@@ -789,6 +791,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
const to = run[i + 1]
|
||||
// A booking whose station is the stop itself leaves a leg that goes nowhere, as on the phone (CW-08).
|
||||
if ((!from.isPlace || !to.isPlace) && from.lat === to.lat && from.lng === to.lng) continue
|
||||
const mode = resolveLegMode(from, to, dfMode)
|
||||
tasks.push(async () => {
|
||||
const seg = await legBetween(from, to, dayId, mode)
|
||||
@@ -1405,26 +1409,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})
|
||||
}
|
||||
|
||||
// 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 transitLegPlanner = (dayId: number, seg?: RouteSegment) => {
|
||||
if (!onPlanTransitLeg) return undefined
|
||||
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
|
||||
if (!leg) return undefined
|
||||
return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time })
|
||||
// The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has
|
||||
// dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08).
|
||||
const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled,
|
||||
onPlan: onPlanTransitLeg, tourOf: placeId => props.plannedTourDurations?.get(placeId)?.tour })
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => {
|
||||
const leg = transitLegs.legFor(dayId, seg)
|
||||
return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined
|
||||
}
|
||||
|
||||
// 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, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
|
||||
ctxMenu.open(e, legModeMenu(m => setLegMode(assignmentId, dayId, m), transitLegPlanner(dayId, seg, assignmentId)))
|
||||
}
|
||||
|
||||
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
|
||||
@@ -2359,6 +2356,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<WalkLateLine late={transitLegs.lateBefore(day, res, merged.slice(idx + 1))} />
|
||||
</div>
|
||||
{transitMeta && (() => {
|
||||
const expanded = expandedTransitIds.has(res.id)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { isCarrierTransport } from '../../utils/dayMerge'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { WalkLateLine, WalkTransitNote } from '../Tours/walk/WalkTransitNote'
|
||||
import { lateForWalk, type WalkLate, type WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -314,7 +316,7 @@ function LineBadge({ leg }: { leg: TransitLeg }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, late, t }: {
|
||||
it: TransitItinerary
|
||||
tzFrom: string
|
||||
tzTo: string
|
||||
@@ -323,6 +325,8 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
onToggle: () => void
|
||||
onAdd: () => void
|
||||
adding: boolean
|
||||
/** CW-08: the next city walk this connection arrives too late for. */
|
||||
late: WalkLate | null
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
||||
@@ -352,6 +356,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<WalkLateLine late={late} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
@@ -460,9 +465,11 @@ interface TransitSearchPanelProps {
|
||||
initialTo?: PickedPlace | null
|
||||
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
|
||||
initialTime?: string | null
|
||||
/** CW-08: the two city walks the leg runs between; said only while the search is for their day. */
|
||||
walk?: WalkTransitContext | null
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null, walk = null }: TransitSearchPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -470,7 +477,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||
const [time, setTime] = useState(initialTime || '09:00')
|
||||
const walkHere = walk?.dayId === day.id ? walk : null
|
||||
// Between two walks the time is when the first one ends, never one made up: unknown, it is chosen.
|
||||
const [time, setTime] = useState(initialTime || (walkHere ? '' : '09:00'))
|
||||
const [arriveBy, setArriveBy] = useState(false)
|
||||
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
||||
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
||||
@@ -629,6 +638,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
|
||||
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
|
||||
const canSearch = !!from && !!to && !!time && !loading
|
||||
|
||||
const segBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
|
||||
@@ -639,6 +649,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
|
||||
<WalkTransitNote walk={walkHere} />
|
||||
{/* from / to — stacked tight on mobile, swap button on desktop only */}
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
|
||||
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||
@@ -707,9 +718,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={search}
|
||||
disabled={!from || !to || loading}
|
||||
disabled={!canSearch}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: canSearch ? 'pointer' : 'default', fontFamily: 'inherit', opacity: canSearch ? 1 : 0.55, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
>
|
||||
<Search size={14} strokeWidth={2.2} />
|
||||
{loading ? t('transit.searching') : t('transit.search')}
|
||||
@@ -743,6 +754,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
||||
onAdd={() => addItinerary(it, idx)}
|
||||
adding={addingIdx === idx}
|
||||
late={lateForWalk(walkHere, day.date ?? null, { date: dateYMDInTz(it.endTime, tzTo), time: timeHHmmInTz(it.endTime, tzTo) })}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// CW-08: the transit search between two city walks, opened from the day plan's
|
||||
// connector with the first walk's end and the next one's start. The mocks are the
|
||||
// ones TransitSearchPanel.test.tsx renders the panel with.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { buildDay, buildPlace, buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit';
|
||||
import TransitSearchPanel from './TransitSearchPanel';
|
||||
|
||||
const { transitApiMock } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() } }));
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
return { ...actual, transitApi: transitApiMock };
|
||||
});
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }));
|
||||
|
||||
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' });
|
||||
const OLD_BRIDGE = { name: 'Old bridge', lat: 52.51, lng: 13.42 };
|
||||
const FISH_MARKET = { name: 'Fish market', lat: 52.5, lng: 13.45 };
|
||||
const WALK: WalkTransitContext = {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
};
|
||||
|
||||
// Berlin, summer time (UTC+2): a tram that departs 12:20 and arrives at `arrives` (UTC).
|
||||
function itinerary(arrives: string) {
|
||||
const at = (time: string) => ({ lat: 52.505, lng: 13.43, time, scheduledTime: null, track: null });
|
||||
return {
|
||||
startTime: '2025-06-01T10:20:00Z',
|
||||
endTime: `2025-06-01T${arrives}:00Z`,
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
walkSeconds: 240,
|
||||
legs: [
|
||||
{
|
||||
mode: 'TRAM',
|
||||
from: { ...at('2025-06-01T10:20:00Z'), name: 'START' },
|
||||
to: { ...at(`2025-06-01T${arrives}:00Z`), name: 'END' },
|
||||
duration: 1800,
|
||||
distance: null,
|
||||
headsign: 'Harbour',
|
||||
line: 'M1',
|
||||
lineColor: null,
|
||||
lineTextColor: null,
|
||||
agency: 'BVG',
|
||||
intermediateStops: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(walk: WalkTransitContext | null, initialTime: string | null = walk?.ends ?? null) {
|
||||
const onAdd = vi.fn().mockResolvedValue({});
|
||||
render(
|
||||
<TransitSearchPanel
|
||||
day={day}
|
||||
days={[day]}
|
||||
places={[buildPlace({ id: 1, name: 'Old town', lat: 52.52, lng: 13.4 })]}
|
||||
onAdd={onAdd}
|
||||
initialFrom={OLD_BRIDGE}
|
||||
initialTo={FISH_MARKET}
|
||||
initialTime={initialTime}
|
||||
walk={walk}
|
||||
/>
|
||||
);
|
||||
return { onAdd };
|
||||
}
|
||||
|
||||
const timeField = () => screen.getByPlaceholderText('00:00') as HTMLInputElement;
|
||||
const searchButton = () => screen.getByRole('button', { name: /^Search$/ });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as never);
|
||||
});
|
||||
|
||||
describe('TransitSearchPanel between two city walks (CW-08)', () => {
|
||||
it('says when the first walk ends and the next starts, and departs from the last stop then', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel(WALK);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
const [from, to] = screen.getAllByPlaceholderText('Search stop or station…') as HTMLInputElement[];
|
||||
expect(from?.value).toBe('Old bridge');
|
||||
expect(to?.value).toBe('Fish market');
|
||||
expect(timeField().value).toBe('12:15');
|
||||
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
// 12:15 in Berlin (UTC+2), from the walk's end and to the next one's start.
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: '52.51,13.42',
|
||||
to: '52.5,13.45',
|
||||
time: '2025-06-01T10:15:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a connection that arrives after the next walk starts, and only that one', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('11:10'), itinerary('10:50')] });
|
||||
renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await screen.findByText(/12:20\D+13:10/);
|
||||
const late = screen.getAllByTestId('transit-walk-late');
|
||||
expect(late).toHaveLength(1);
|
||||
expect(late[0]).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(screen.getByText(/12:20\D+13:10/).closest('button')).toContainElement(late[0] ?? null);
|
||||
});
|
||||
|
||||
it('says an end that leaves a time out is the earliest', () => {
|
||||
renderPanel({ ...WALK, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town ends at 12:15 at the earliest; not all of its times are known yet.'
|
||||
);
|
||||
});
|
||||
|
||||
it('says a walk that ends after midnight does so, and makes up no time on this day', () => {
|
||||
renderPanel({ ...WALK, ends: null, endsNextDay: true, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town only ends after midnight, so choose a departure time.'
|
||||
);
|
||||
expect(timeField().value).toBe('');
|
||||
});
|
||||
|
||||
it('makes up no time without an end, and waits for one to be chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel({ ...WALK, ends: null, nextStarts: null });
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'When Old town ends is not known yet, so choose a departure time.'
|
||||
);
|
||||
expect(screen.queryByTestId('transit-walk-next')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('');
|
||||
expect(searchButton()).toBeDisabled();
|
||||
|
||||
await user.type(timeField(), '1230');
|
||||
await user.tab();
|
||||
expect(searchButton()).toBeEnabled();
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({ time: '2025-06-01T10:30:00.000Z' });
|
||||
});
|
||||
|
||||
it('on another day than the walks, speaks of them no more and departs at the usual time', () => {
|
||||
renderPanel({ ...WALK, dayId: 11 }, null);
|
||||
expect(screen.queryByTestId('transit-walk-end')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('09:00');
|
||||
});
|
||||
|
||||
it('adds a connection only once it is opened and taken, between the two stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('10:50')] });
|
||||
const { onAdd } = renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await user.click(await screen.findByText(/12:20\D+12:50/));
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }));
|
||||
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalledTimes(1));
|
||||
const payload = onAdd.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({ type: 'transit', title: 'Old bridge → Fish market', day_id: 10 });
|
||||
expect(payload.endpoints.map((point: { name: string }) => point.name)).toEqual(['Old bridge', 'Fish market']);
|
||||
});
|
||||
});
|
||||
@@ -519,6 +519,15 @@ describe('TransportModal', () => {
|
||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-023b: a leg between two city walks opens the search with what it says of them (CW-08)', () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as Day[];
|
||||
const walk = { dayId: 10, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' };
|
||||
const transitPrefill = { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, time: '12:15', walk };
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated transitPrefill={transitPrefill} places={[]} accommodations={[]} />);
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01' }),
|
||||
|
||||
@@ -19,7 +19,8 @@ import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
||||
import TransitSearchPanel from './TransitSearchPanel'
|
||||
import type { TransitPrefill } from './transitLeg'
|
||||
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
||||
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
|
||||
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
|
||||
@@ -70,7 +71,7 @@ interface TransportModalProps {
|
||||
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
||||
tripHasDates?: boolean
|
||||
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
||||
transitPrefill?: TransitPrefill | null
|
||||
/** Trip members + guests, for the traveler picker (#1517). */
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
@@ -304,6 +305,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
initialTime={transitPrefill?.time ?? null}
|
||||
walk={transitPrefill?.walk ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-007
|
||||
|
||||
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
|
||||
@@ -69,4 +69,11 @@ describe('buildTransitLeg', () => {
|
||||
expect(buildTransitLeg(undefined, 1, names, assignments, [])).toBeNull()
|
||||
expect(buildTransitLeg({ from: [1, 1] } as unknown as RouteSegment, 1, names, assignments, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITLEG-007: a leg leaving a loop tour does not depart when the tour starts (CW-08)', () => {
|
||||
// A tour's place sits at its start; a loop ends there too, but its time is when it starts.
|
||||
const loop = { ...visit(31, 3, 'Old town loop', 52.52, 13.4, '10:00'), tour_place_id: 31 } as Assignment
|
||||
const tours = { '3': [loop, visit(32, 3, 'Harbour', 52.5, 13.45)] }
|
||||
expect(buildTransitLeg(seg([52.52, 13.4], [52.5, 13.45]), 3, buildTransitNameIndex(tours, [], []), tours, [])?.time).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
import type { PickedPlace } from './TransitSearchPanel'
|
||||
|
||||
/** What the transit search is opened with for one leg of a day. */
|
||||
@@ -8,6 +9,20 @@ export interface TransitLeg {
|
||||
time: string | null
|
||||
}
|
||||
|
||||
/** A leg as a view hands it on to open the search: its day and, between two city walks, what it says of them (CW-08). */
|
||||
export interface PlannedTransitLeg extends TransitLeg {
|
||||
dayId: number
|
||||
walk?: WalkTransitContext
|
||||
}
|
||||
|
||||
/** What the search is seeded with: a leg, or the ends of a journey whose route is changed. */
|
||||
export interface TransitPrefill {
|
||||
from?: PickedPlace | null
|
||||
to?: PickedPlace | null
|
||||
time?: string | null
|
||||
walk?: WalkTransitContext | 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
|
||||
@@ -48,7 +63,9 @@ function originDepartureTime(
|
||||
assignments: Record<string, Assignment[]>,
|
||||
reservations: Reservation[],
|
||||
): string | null {
|
||||
for (const a of assignments[String(dayId)] ?? []) {
|
||||
// A tour's place is where it starts and its time when it starts: a leg leaving a loop is no departure then.
|
||||
const stops = (assignments[String(dayId)] ?? []).filter(a => a.tour_place_id == null || a.tour_place_id !== a.place?.id)
|
||||
for (const a of stops) {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import type { RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { useTransitLegPlanner, type TransitLegPlannerDeps } from './useTransitLegPlanner';
|
||||
|
||||
// CW-08: the transit leg behind the desktop day plan and the phone timeline.
|
||||
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: vi.fn() } };
|
||||
});
|
||||
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const museum = buildPlace({ id: 53, name: 'Museum', lat: 52.47, lng: 13.48, place_time: '9:05' });
|
||||
const cafe = buildPlace({ id: 54, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
const walkA = buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
});
|
||||
const walkB = buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
});
|
||||
const assignments = {
|
||||
'10': [walkB, walkA],
|
||||
'11': [
|
||||
buildAssignment({ id: 301, day_id: 11, order_index: 0, place: museum }),
|
||||
buildAssignment({ id: 302, day_id: 11, order_index: 1, place: cafe }),
|
||||
],
|
||||
};
|
||||
|
||||
function walk(place: { id: number; name: string }): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
duration: 90,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
} as TourListItem;
|
||||
}
|
||||
const tours = new Map([walk(oldTown), walk(harbour)].map((tour) => [tour.place_id, tour]));
|
||||
|
||||
const BETWEEN_WALKS = { from: [52.51, 13.42], to: [52.5, 13.45] } as unknown as RouteSegment;
|
||||
const MUSEUM_TO_CAFE = { from: [52.47, 13.48], to: [52.48, 13.47] } as unknown as RouteSegment;
|
||||
|
||||
function setup(overrides: Partial<TransitLegPlannerDeps> = {}) {
|
||||
const onPlan = vi.fn();
|
||||
const deps: TransitLegPlannerDeps = {
|
||||
tripId: 1,
|
||||
assignments,
|
||||
accommodations: [],
|
||||
reservations: [],
|
||||
places: [oldTown, harbour, museum, cafe],
|
||||
toursEnabled: true,
|
||||
tourOf: (placeId) => tours.get(placeId),
|
||||
onPlan,
|
||||
...overrides,
|
||||
};
|
||||
const { result, unmount } = renderHook(() => useTransitLegPlanner(deps), { wrapper: TranslationProvider });
|
||||
return { onPlan, result, unmount };
|
||||
}
|
||||
|
||||
const detail = (placeId: number, names: [string, string]) => ({
|
||||
tour: tours.get(placeId),
|
||||
waypoints: names.map((name, sequence) => ({
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
role: 'via',
|
||||
sequence,
|
||||
key: `${placeId}-${sequence}`,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
|
||||
/** Walk reads that answer only once let go, and what each was asked with. */
|
||||
function heldReads() {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
gate.then(() => detail(placeId, ['First', 'Last']) as never)
|
||||
);
|
||||
const signals = () => vi.mocked(tourRepo.detail).mock.calls.map((call) => call[2]?.aborted);
|
||||
// A turn of the event loop lets every read and what follows it settle.
|
||||
const settle = async () => {
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
return { signals, settle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(tourRepo.detail).mockReset();
|
||||
});
|
||||
|
||||
describe('useTransitLegPlanner', () => {
|
||||
it('offers no leg without a way to open the search, and the leg as for any connector with one', () => {
|
||||
expect(setup({ onPlan: undefined }).result.current.legFor(11, MUSEUM_TO_CAFE)).toBeNull();
|
||||
expect(setup().result.current.legFor(11, MUSEUM_TO_CAFE)).toEqual({
|
||||
from: { name: 'Museum', lat: 52.47, lng: 13.48 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: '09:05',
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the search for an ordinary leg at once, with nothing said of walks', () => {
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(11, leg, 301);
|
||||
expect(onPlan.mock.calls[0]?.[0]).toStrictEqual({ dayId: 11, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('between two walks, opens it from the last stop to the first once both are read, when the first one ends', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
Promise.resolve(
|
||||
detail(placeId, placeId === 51 ? ['Town hall', 'Old bridge'] : ['Fish market', 'Lighthouse']) as never
|
||||
)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(onPlan).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(vi.mocked(tourRepo.detail).mock.calls.map((call) => call[1])).toEqual([51, 52]);
|
||||
});
|
||||
|
||||
it('still opens it where a walk cannot be read, naming that end after its walk', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
placeId === 51
|
||||
? Promise.reject(new Error('Tour not available offline'))
|
||||
: // Route ends without keys stand in for stops this device does not hold.
|
||||
Promise.resolve({
|
||||
tour: tours.get(52),
|
||||
waypoints: [{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0 }],
|
||||
} as never)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
await waitFor(() => expect(onPlan).toHaveBeenCalledTimes(1));
|
||||
expect(onPlan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: { name: 'End of Old town' },
|
||||
to: { name: 'Start of Harbour' },
|
||||
time: '12:15',
|
||||
});
|
||||
});
|
||||
|
||||
it('with tours off, treats the leg between two walks as any other', () => {
|
||||
const { onPlan, result } = setup({ toursEnabled: false });
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 10, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens nothing without a way to open the search', () => {
|
||||
const { result } = setup({ onPlan: undefined });
|
||||
const leg = { from: { name: '', lat: 52.51, lng: 13.42 }, to: { name: '', lat: 52.5, lng: 13.45 }, time: null };
|
||||
expect(() => result.current.plan(10, leg, walkA.id)).not.toThrow();
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens only the leg asked for last, and drops the walks still being read for an earlier one', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result } = setup();
|
||||
const between = result.current.legFor(10, BETWEEN_WALKS);
|
||||
const ordinary = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!between || !ordinary) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(11, ordinary, 301);
|
||||
expect(reads.signals()).toEqual([true, true, true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).toHaveBeenCalledTimes(1);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 11, ...ordinary });
|
||||
});
|
||||
|
||||
it('opens nothing once the view is gone before the walks are read', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result, unmount } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(reads.signals()).toEqual([false, false]);
|
||||
unmount();
|
||||
expect(reads.signals()).toEqual([true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says when a saved journey now arrives after the walk it leads to starts, while tours are on', () => {
|
||||
const journey = buildReservation({ id: 300, type: 'transit', reservation_end_time: '2025-06-01T13:10' });
|
||||
const following = [{ type: 'place', sortKey: 1, data: walkB }] as MergedItem[];
|
||||
const day = { date: '2025-06-01' };
|
||||
expect(setup().result.current.lateBefore(day, journey, following)).toEqual({ walk: 'Harbour', time: '13:00' });
|
||||
expect(setup({ toursEnabled: false }).result.current.lateBefore(day, journey, following)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation, RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { projectDayItinerary } from '../Map/dayTourProjection';
|
||||
import { readWalkStations } from '../Tours/walk/walkStationsRead';
|
||||
import { journeyLate, walkPairAt, walkTransitLeg, type TourOf, type WalkLate } from '../Tours/walk/walkTransit';
|
||||
import { buildTransitLeg, buildTransitNameIndex, type PlannedTransitLeg, type TransitLeg } from './transitLeg';
|
||||
|
||||
export interface TransitLegPlannerDeps {
|
||||
tripId: number | string;
|
||||
assignments: AssignmentsMap;
|
||||
accommodations: Accommodation[];
|
||||
reservations: Reservation[];
|
||||
places: Place[];
|
||||
toursEnabled: boolean;
|
||||
tourOf: TourOf;
|
||||
/** Opens the search for a leg; without it no leg offers one, as on a trip without dates. */
|
||||
onPlan: ((leg: PlannedTransitLeg) => void) | undefined;
|
||||
}
|
||||
|
||||
/** A walk's stops, or null where this device cannot read them. */
|
||||
async function stopsOrNull(tripId: number | string, placeId: number, signal: AbortSignal) {
|
||||
try {
|
||||
return await readWalkStations(tripId, placeId, signal);
|
||||
} catch {
|
||||
// They only name the leg's two ends: without them it opens named after the walks.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public transit for one leg of a day (#2398), behind the desktop day plan and
|
||||
* the phone timeline alike: the leg's two ends and the time its origin is
|
||||
* planned for. Between two city walks (CW-08) it runs from the first walk's
|
||||
* last stop to the next one's first, departing when the first one ends, and a
|
||||
* saved journey that now arrives after the next walk starts says so. Nothing
|
||||
* is saved here: the search opens, and only the connection picked there is
|
||||
* added to the day. Only the last leg asked for opens it, and none after the
|
||||
* view is gone.
|
||||
*/
|
||||
export function useTransitLegPlanner({
|
||||
tripId,
|
||||
assignments,
|
||||
accommodations,
|
||||
reservations,
|
||||
places,
|
||||
toursEnabled,
|
||||
tourOf,
|
||||
onPlan,
|
||||
}: TransitLegPlannerDeps) {
|
||||
const { t } = useTranslation();
|
||||
// Coordinates back to names, over every located place, hotel and booking endpoint of the trip.
|
||||
const names = useMemo(
|
||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
||||
[assignments, accommodations, reservations]
|
||||
);
|
||||
|
||||
const legFor = useCallback(
|
||||
(dayId: number, seg?: RouteSegment): TransitLeg | null =>
|
||||
onPlan ? buildTransitLeg(seg, dayId, names, assignments, reservations) : null,
|
||||
[onPlan, names, assignments, reservations]
|
||||
);
|
||||
|
||||
// The walks' stops being read for the leg asked for last; a later leg, or leaving the view, drops them.
|
||||
const pending = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => pending.current?.abort(), []);
|
||||
|
||||
/** Opens the search for a leg, told by the entry it leaves where the connector carries one. */
|
||||
const plan = useCallback(
|
||||
(dayId: number, leg: TransitLeg, originId?: number) => {
|
||||
if (!onPlan) return;
|
||||
pending.current?.abort();
|
||||
const day = [...(assignments[String(dayId)] ?? [])].sort((a, b) => a.order_index - b.order_index);
|
||||
const pair = toursEnabled ? walkPairAt(projectDayItinerary(day, true, places), leg, tourOf, originId) : null;
|
||||
if (!pair) {
|
||||
onPlan({ dayId, ...leg });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
pending.current = controller;
|
||||
// The stops name the ends; read from this device's copy when offline, so the search waits only for them.
|
||||
void Promise.all([
|
||||
stopsOrNull(tripId, pair.first.item.placeId, controller.signal),
|
||||
stopsOrNull(tripId, pair.next.item.placeId, controller.signal),
|
||||
]).then(([first, next]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
onPlan({ dayId, ...walkTransitLeg({ leg, dayId, pair, stops: { first, next }, places, t }) });
|
||||
});
|
||||
},
|
||||
[onPlan, assignments, toursEnabled, places, tourOf, tripId, t]
|
||||
);
|
||||
|
||||
/** A saved journey on a day that arrives after the walk it leads to starts; null otherwise. */
|
||||
const lateBefore = useCallback(
|
||||
(day: Pick<Day, 'date'>, reservation: Reservation, following: readonly MergedItem[]): WalkLate | null =>
|
||||
toursEnabled ? journeyLate({ reservation, following, date: day.date ?? null, tourOf }) : null,
|
||||
[toursEnabled, tourOf]
|
||||
);
|
||||
|
||||
return { legFor, plan, lateBefore };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AlertTriangle, Footprints } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatClockTime } from '../../../utils/formatters';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { WalkLate, WalkTransitContext } from './walkTransit';
|
||||
|
||||
function useIs12h(): boolean {
|
||||
return useSettingsStore((state) => state.settings.time_format) === '12h';
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-08: what the search between two city walks goes by, above it: when the
|
||||
* first walk ends, at the earliest where a time of it is not known, that it
|
||||
* ends after midnight, or that the end is not known, and then a time is to be
|
||||
* chosen; and when the next one starts.
|
||||
*/
|
||||
export function WalkTransitNote({ walk }: { walk: WalkTransitContext | null }) {
|
||||
const { t } = useTranslation();
|
||||
const is12h = useIs12h();
|
||||
if (!walk) return null;
|
||||
const params = { walk: walk.fromWalk, time: formatClockTime(walk.ends, is12h) };
|
||||
let end = t('tours.walk.transit.ends', params);
|
||||
if (walk.endsNextDay) end = t('tours.walk.transit.endsNextDay', params);
|
||||
else if (!walk.ends) end = t('tours.walk.transit.endUnknown', params);
|
||||
else if (walk.endsAtLeast) end = t('tours.walk.transit.endsAtLeast', params);
|
||||
return (
|
||||
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
|
||||
<div data-testid="transit-walk-end">{end}</div>
|
||||
{walk.nextStarts && (
|
||||
<div data-testid="transit-walk-next">
|
||||
{t('tours.walk.transit.nextStarts', { walk: walk.toWalk, time: formatClockTime(walk.nextStarts, is12h) })}
|
||||
</div>
|
||||
)}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-08: a connection, or a journey saved before, that arrives after the next
|
||||
* walk starts. Said on one line that wraps; nothing is moved or replaced.
|
||||
*/
|
||||
export function WalkLateLine({ late }: { late?: WalkLate | null }) {
|
||||
const { t } = useTranslation();
|
||||
const is12h = useIs12h();
|
||||
if (!late) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="transit-walk-late"
|
||||
className="mt-1.5 flex items-start gap-1 text-warning"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<AlertTriangle size={12} strokeWidth={2.2} aria-hidden="true" className="mt-[2px] flex-none" />
|
||||
<span className="min-w-0 break-words">
|
||||
{t('tours.walk.transit.late', { walk: late.walk, time: formatClockTime(late.time, is12h) })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clockOf, minutesOf } from '@trek/shared';
|
||||
import { clockOf } from '@trek/shared';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { AssignmentsMap, Day } from '../../../types';
|
||||
import { walkStartOf } from './walkSchedule';
|
||||
|
||||
/** A day the walk is on, with its place in the trip and the walk's start that day. */
|
||||
export interface WalkDayOption {
|
||||
@@ -12,15 +13,14 @@ export interface WalkDayOption {
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02: the days a walk is on, in the order of the trip, each with its start:
|
||||
* the time set on that day's entry, else the place's own time, as the plan
|
||||
* shows it. A time that cannot be read is no start.
|
||||
* CW-02: the days a walk is on, in the order of the trip, each with its start
|
||||
* on that day (`walkStartOf`). A time that cannot be read is no start.
|
||||
*/
|
||||
function walkDayOptions(placeId: number, days: readonly Day[], assignments: AssignmentsMap): WalkDayOption[] {
|
||||
return days.flatMap((day, index) => {
|
||||
const assignment = (assignments[String(day.id)] ?? []).find((entry) => entry.place_id === placeId);
|
||||
if (!assignment) return [];
|
||||
const minutes = minutesOf(assignment.assignment_time ?? assignment.place?.place_time);
|
||||
const minutes = walkStartOf(assignment);
|
||||
return [{ day, index, start: minutes === null ? null : clockOf(minutes) }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { walkStationsOf } from '@trek/shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { tourRepo } from '../../../repo/tourRepo';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-03: a synced walk's stations all carry a key. Points without any are the
|
||||
* route's ends, which this device reads in their place while it holds none of
|
||||
* the stations, so they are not listed as the walk's. A walk saved offline has
|
||||
* no keys before it synced, and its stations stay.
|
||||
*/
|
||||
function ownStations(points: TourPlannerWaypoint[], placeId: number): TourPlannerWaypoint[] {
|
||||
return placeId < 0 || points.some((point) => point.key) ? points : [];
|
||||
}
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { readWalkStations } from './walkStationsRead';
|
||||
|
||||
/**
|
||||
* CW-03: a saved walk's stations in order: null while they load, none where
|
||||
@@ -30,12 +18,9 @@ export function useWalkStations(
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
tourRepo
|
||||
.detail(tripId, placeId, controller.signal)
|
||||
.then((detail) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const points = walkStationsOf(plannerWaypointsOf(detail.waypoints, randomId));
|
||||
setStations({ placeId, points: ownStations(points, placeId) });
|
||||
readWalkStations(tripId, placeId, controller.signal)
|
||||
.then((points) => {
|
||||
if (!controller.signal.aborted) setStations({ placeId, points });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setStations({ placeId, points: [] });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { minutesOf, stationArrivals, stationFit, stationHoursOn, type StationFit } from '@trek/shared';
|
||||
import type { Day, Place } from '../../../types';
|
||||
import type { Assignment, Day, Place } from '../../../types';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
@@ -46,6 +46,15 @@ export interface WalkScheduleInput {
|
||||
placeOf: LinkedPlaceOf;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02, CW-04, CW-08: when a walk starts on a day, in minutes after midnight:
|
||||
* the time set on that day's entry, else the place's own time, as the plan
|
||||
* shows it. Null where neither is set or it cannot be read.
|
||||
*/
|
||||
export function walkStartOf(assignment: Pick<Assignment, 'assignment_time' | 'place'>): number | null {
|
||||
return minutesOf(assignment.assignment_time ?? assignment.place?.place_time);
|
||||
}
|
||||
|
||||
function emptySchedule(state: WalkScheduleState): WalkSchedule {
|
||||
return { state, byStation: new Map(), anyHours: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { walkStationsOf } from '@trek/shared';
|
||||
import { tourRepo } from '../../../repo/tourRepo';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-03: a synced walk's stations all carry a key. Points without any are the
|
||||
* route's ends, which this device reads in their place while it holds none of
|
||||
* the stations, so they are not listed as the walk's. A walk saved offline has
|
||||
* no keys before it synced, and its stations stay.
|
||||
*/
|
||||
function ownStations(points: TourPlannerWaypoint[], placeId: number): TourPlannerWaypoint[] {
|
||||
return placeId < 0 || points.some((point) => point.key) ? points : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03, CW-08: a saved walk's stations in order, as this device can read
|
||||
* them: from the server, else from its offline copy. Rejects where neither
|
||||
* has the walk.
|
||||
*/
|
||||
export async function readWalkStations(
|
||||
tripId: number | string,
|
||||
placeId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<TourPlannerWaypoint[]> {
|
||||
const detail = await tourRepo.detail(tripId, placeId, signal);
|
||||
return ownStations(walkStationsOf(plannerWaypointsOf(detail.waypoints, randomId)), placeId);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Assignment } from '../../../types';
|
||||
import type { MergedItem } from '../../../utils/dayMerge';
|
||||
import type { DayItineraryItem } from '../../Map/dayTourProjection';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { walkStartOf } from './walkSchedule';
|
||||
import {
|
||||
arrivesAfterStart,
|
||||
journeyLate,
|
||||
lateForWalk,
|
||||
walkEndOf,
|
||||
walkPairAt,
|
||||
walkTransitLeg,
|
||||
type WalkPair,
|
||||
type WalkTransitContext,
|
||||
} from './walkTransit';
|
||||
|
||||
// CW-08: public transit between two city walks on one day.
|
||||
|
||||
type Spot = { lat: number; lng: number };
|
||||
|
||||
function assignment(placeId: number, time: { assignment?: string | null; place?: string | null } = {}): Assignment {
|
||||
return {
|
||||
id: placeId + 100,
|
||||
day_id: 10,
|
||||
place_id: placeId,
|
||||
order_index: 0,
|
||||
tour_place_id: placeId,
|
||||
assignment_time: time.assignment ?? null,
|
||||
place: { id: placeId, name: `Place ${placeId}`, place_time: time.place ?? null },
|
||||
} as unknown as Assignment;
|
||||
}
|
||||
|
||||
function tourItem(
|
||||
placeId: number,
|
||||
start: Spot,
|
||||
end: Spot,
|
||||
time: { assignment?: string | null; place?: string | null } = {}
|
||||
): Extract<DayItineraryItem, { kind: 'tour' }> {
|
||||
const loop = start.lat === end.lat && start.lng === end.lng;
|
||||
return { kind: 'tour', assignment: assignment(placeId, time), placeId, start, end, geometry: [], loop, valid: true };
|
||||
}
|
||||
|
||||
function tour(placeId: number, name: string, overrides: Partial<TourListItem> = {}): TourListItem {
|
||||
return {
|
||||
place_id: placeId,
|
||||
name,
|
||||
tour_type: 'walk',
|
||||
duration: 90,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
...overrides,
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
const OLD_TOWN = tour(1, 'Old town');
|
||||
const HARBOUR = tour(2, 'Harbour');
|
||||
const HIKE = tour(3, 'Ridge', { tour_type: 'hike' });
|
||||
const tours = new Map([OLD_TOWN, HARBOUR, HIKE].map((item) => [item.place_id, item]));
|
||||
const tourOf = (placeId: number) => tours.get(placeId);
|
||||
|
||||
const A_START = { lat: 52.52, lng: 13.4 };
|
||||
const A_END = { lat: 52.51, lng: 13.42 };
|
||||
const B_START = { lat: 52.5, lng: 13.45 };
|
||||
const B_END = { lat: 52.49, lng: 13.46 };
|
||||
const leg = (from: Spot, to: Spot) => ({ from: { name: '', ...from }, to: { name: '', ...to }, time: '10:00' });
|
||||
const stop = (id: string, name?: string, linkedPlaceId?: number): TourPlannerWaypoint => ({
|
||||
id,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
role: 'via',
|
||||
...(name ? { name } : {}),
|
||||
...(linkedPlaceId ? { linkedPlaceId } : {}),
|
||||
});
|
||||
const t = (key: string, params?: Record<string, string | number | null>) => `${key} ${JSON.stringify(params ?? {})}`;
|
||||
|
||||
describe('walkPairAt', () => {
|
||||
const first = tourItem(1, A_START, A_END, { place: '10:00' });
|
||||
const next = tourItem(2, B_START, B_END, { place: '13:00' });
|
||||
const located = (placeId: number, at: Spot, extra: Partial<Assignment> = {}): DayItineraryItem => ({
|
||||
kind: 'stop',
|
||||
assignment: { ...assignment(placeId), tour_place_id: null, place: { id: placeId, ...at }, ...extra } as Assignment,
|
||||
});
|
||||
|
||||
it('finds the walk the leg leaves at its end and the next one it reaches at its start', () => {
|
||||
const pair = walkPairAt([first, next], leg(A_END, B_START), tourOf, first.assignment.id);
|
||||
expect(pair?.first.tour.name).toBe('Old town');
|
||||
expect(pair?.next.tour.name).toBe('Harbour');
|
||||
});
|
||||
|
||||
it('reads past an item the route does not go to: a stop without a place, out of the route, or a booked night', () => {
|
||||
const unlocated: DayItineraryItem = { kind: 'stop', assignment: assignment(9) };
|
||||
const excluded = located(8, A_END, { route_excluded: true });
|
||||
const night = located(7, A_END, { accommodation_id: 3 });
|
||||
expect(walkPairAt([first, unlocated, excluded, night, next], leg(A_END, B_START), tourOf, 101)?.next.item).toBe(
|
||||
next
|
||||
);
|
||||
});
|
||||
|
||||
it('is no pair where the route goes to a stop first, even one at the walk end or the next walk start', () => {
|
||||
expect(walkPairAt([first, located(9, A_END), next], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([first, located(9, B_START), next], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
});
|
||||
|
||||
it('pairs a leg with the walk it leaves where two walks of the day end at one spot', () => {
|
||||
// Two loops from the hotel, each followed by a walk that starts at the same square.
|
||||
const hotel = { lat: 52.53, lng: 13.38 };
|
||||
const anyWalk = (placeId: number) => tour(placeId, `Walk ${placeId}`);
|
||||
const morning = tourItem(11, hotel, hotel, { place: '09:00' });
|
||||
const afternoon = tourItem(13, hotel, hotel, { place: '14:00' });
|
||||
const items = [morning, tourItem(12, B_START, B_END), afternoon, tourItem(14, B_START, B_END)];
|
||||
|
||||
const pair = walkPairAt(items, leg(hotel, B_START), anyWalk, afternoon.assignment.id);
|
||||
expect(pair?.first.item).toBe(afternoon);
|
||||
expect(pair?.next.item).toBe(items[3]);
|
||||
if (!pair) throw new Error('no pair');
|
||||
const searched = walkTransitLeg({
|
||||
leg: leg(hotel, B_START),
|
||||
dayId: 10,
|
||||
pair,
|
||||
stops: { first: [], next: [] },
|
||||
places: [],
|
||||
t,
|
||||
});
|
||||
expect(searched.time).toBe('16:15');
|
||||
expect(walkPairAt(items, leg(hotel, B_START), anyWalk, morning.assignment.id)?.next.item).toBe(items[1]);
|
||||
});
|
||||
|
||||
it('is no pair for a leg that leaves elsewhere or reaches no walk', () => {
|
||||
const hike = tourItem(3, B_START, B_END);
|
||||
const track = tourItem(4, B_START, B_END);
|
||||
expect(walkPairAt([first, hike], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
// A place with a track of its own is no tour of the list.
|
||||
expect(walkPairAt([first, track], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([hike, next], leg(B_END, B_START), tourOf, 103)).toBeNull();
|
||||
// The leg leaves the walk's start, not its end.
|
||||
expect(walkPairAt([first, next], leg(A_START, B_START), tourOf, 101)).toBeNull();
|
||||
// A leg from the walk to a place, from a walk with nothing after it, or from no entry of the day.
|
||||
expect(walkPairAt([first, located(9, { lat: 1, lng: 1 })], leg(A_END, { lat: 1, lng: 1 }), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([first], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([first, next], leg(A_END, B_START), tourOf, undefined)).toBeNull();
|
||||
expect(walkPairAt([first, next], leg(A_END, B_START), tourOf, 102)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkEndOf', () => {
|
||||
it('adds the walking time and the stays to the start', () => {
|
||||
expect(walkEndOf(600, OLD_TOWN)).toEqual({ minutes: 735, atLeast: false, nextDay: false });
|
||||
});
|
||||
|
||||
it('is at the earliest while a stay is not known, or the list says nothing of the stops', () => {
|
||||
expect(walkEndOf(600, tour(1, 'A', { station_stays_unknown: 1 }))).toMatchObject({ minutes: 735, atLeast: true });
|
||||
expect(walkEndOf(600, tour(1, 'A', { station_stay_minutes: null }))).toMatchObject({ minutes: 690, atLeast: true });
|
||||
});
|
||||
|
||||
it('takes a total entered by hand before the one worked out', () => {
|
||||
expect(walkEndOf(600, tour(1, 'A', { planned_duration_minutes: 240, station_stays_unknown: 1 }))).toMatchObject({
|
||||
minutes: 840,
|
||||
atLeast: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('is not known without a start or without any time', () => {
|
||||
expect(walkEndOf(null, OLD_TOWN)).toBeNull();
|
||||
expect(walkEndOf(600, tour(1, 'A', { duration: null, station_stay_minutes: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ends on the next day from midnight on', () => {
|
||||
expect(walkEndOf(23 * 60, OLD_TOWN)).toEqual({ minutes: 1515, atLeast: false, nextDay: true });
|
||||
expect(walkEndOf(24 * 60 - 135, OLD_TOWN)).toMatchObject({ nextDay: true });
|
||||
expect(walkEndOf(24 * 60 - 136, OLD_TOWN)).toMatchObject({ nextDay: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkStartOf', () => {
|
||||
it('takes the time on the day before the place time, and reads neither when none is set', () => {
|
||||
expect(walkStartOf(assignment(1, { assignment: '9:05', place: '11:00' }))).toBe(545);
|
||||
expect(walkStartOf(assignment(1, { place: '11:00' }))).toBe(660);
|
||||
expect(walkStartOf(assignment(1))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkTransitLeg', () => {
|
||||
const pair = (firstTime: { place?: string | null } = { place: '10:00' }, loop = false): WalkPair => ({
|
||||
first: { item: tourItem(1, A_START, loop ? A_START : A_END, firstTime), tour: OLD_TOWN },
|
||||
next: { item: tourItem(2, B_START, B_END, { assignment: '9:05' }), tour: HARBOUR },
|
||||
});
|
||||
const places = [{ id: 7, name: 'Fish market' }];
|
||||
|
||||
it('runs from the last stop to the first, departing when the first walk ends', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: pair(),
|
||||
stops: { first: [stop('a', 'Town hall'), stop('b', 'Old bridge')], next: [stop('c', undefined, 7)] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: { name: 'Old bridge', ...A_END },
|
||||
to: { name: 'Fish market', ...B_START },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '09:05',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('names an end after its walk where the stops are unread or unnamed, and makes up no time', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: pair({ place: null }),
|
||||
stops: { first: null, next: [stop('c')] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.from.name).toBe('tours.walk.transit.endOf {"walk":"Old town"}');
|
||||
expect(result.to.name).toBe('tours.walk.transit.startOf {"walk":"Harbour"}');
|
||||
// The leg's own time is the walk's start, never when it ends.
|
||||
expect(result.time).toBeNull();
|
||||
expect(result.walk).toMatchObject({ ends: null, endsNextDay: false, endsAtLeast: false });
|
||||
});
|
||||
|
||||
it('says a walk ending after midnight ends on the next day, and makes up no time on this one', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: pair({ place: '23:00' }),
|
||||
stops: { first: [], next: [] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.time).toBeNull();
|
||||
expect(result.walk).toMatchObject({ ends: null, endsNextDay: true });
|
||||
});
|
||||
|
||||
it('says nothing of the next start, nor of an arrival after it, where the next walk has no time', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: { ...pair(), next: { item: tourItem(2, B_START, B_END), tour: HARBOUR } },
|
||||
stops: { first: [], next: [] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.walk.nextStarts).toBeNull();
|
||||
expect(lateForWalk(result.walk, '2025-06-01', { date: '2025-06-01', time: '23:50' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ends a loop at its first stop again', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_START, B_START),
|
||||
dayId: 10,
|
||||
pair: pair({ place: '10:00' }, true),
|
||||
stops: { first: [stop('a', 'Town hall'), stop('b', 'Old bridge')], next: [] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.from).toEqual({ name: 'Town hall', ...A_START });
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrivesAfterStart', () => {
|
||||
it('compares the clock on the day and counts a later date as late', () => {
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, '2025-06-01', '13:00')).toBe(true);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:00' }, '2025-06-01', '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '12:50' }, '2025-06-01', '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-02', time: '00:10' }, '2025-06-01', '13:00')).toBe(true);
|
||||
expect(arrivesAfterStart({ date: '2025-05-31', time: '23:50' }, '2025-06-01', '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: null, time: '13:10' }, '2025-06-01', '13:00')).toBe(true);
|
||||
});
|
||||
|
||||
it('says nothing without a start, a date or an arrival time', () => {
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, '2025-06-01', null)).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, null, '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: null }, '2025-06-01', '13:00')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lateForWalk', () => {
|
||||
const walk: WalkTransitContext = {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
};
|
||||
|
||||
it('names the next walk a connection arrives too late for', () => {
|
||||
expect(lateForWalk(walk, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toEqual({
|
||||
walk: 'Harbour',
|
||||
time: '13:00',
|
||||
});
|
||||
expect(lateForWalk(walk, '2025-06-01', { date: '2025-06-01', time: '12:50' })).toBeNull();
|
||||
expect(lateForWalk({ ...walk, nextStarts: null }, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toBeNull();
|
||||
expect(lateForWalk(null, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyLate', () => {
|
||||
const journey = { type: 'transit', reservation_end_time: '2025-06-01T13:10' };
|
||||
const walkAt = (placeId: number, time: string | null) =>
|
||||
({ type: 'place', sortKey: 1, data: assignment(placeId, { place: time }) }) as MergedItem;
|
||||
const note = { type: 'note', sortKey: 0, data: {} } as MergedItem;
|
||||
const late = (following: MergedItem[], reservation = journey) =>
|
||||
journeyLate({ reservation, following, date: '2025-06-01', tourOf });
|
||||
|
||||
it('names the walk a saved journey now arrives too late for, notes aside', () => {
|
||||
expect(late([note, walkAt(2, '13:00')])).toEqual({ walk: 'Harbour', time: '13:00' });
|
||||
expect(late([walkAt(2, '13:30')])).toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing for another booking, another kind of tour, a walk without a start or a stop between', () => {
|
||||
expect(late([walkAt(2, '13:00')], { type: 'train', reservation_end_time: '2025-06-01T13:10' })).toBeNull();
|
||||
expect(late([walkAt(3, '13:00')])).toBeNull();
|
||||
expect(late([walkAt(2, null)])).toBeNull();
|
||||
expect(late([{ type: 'transport', sortKey: 1, data: {} } as MergedItem, walkAt(2, '13:00')])).toBeNull();
|
||||
expect(late([])).toBeNull();
|
||||
const plainPlace = {
|
||||
type: 'place',
|
||||
sortKey: 1,
|
||||
data: { ...assignment(2, { place: '13:00' }), tour_place_id: null },
|
||||
};
|
||||
expect(late([plainPlace as MergedItem])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { clockOf, minutesOf, type TourListItem } from '@trek/shared';
|
||||
import type { Assignment, Place, Reservation, TranslationFn } from '../../../types';
|
||||
import type { MergedItem } from '../../../utils/dayMerge';
|
||||
import { splitReservationDateTime } from '../../../utils/formatters';
|
||||
import type { DayItineraryItem } from '../../Map/dayTourProjection';
|
||||
import type { TransitLeg } from '../../Planner/transitLeg';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { tourPlannedTimes } from '../tourPresentation';
|
||||
import { walkStartOf } from './walkSchedule';
|
||||
|
||||
/**
|
||||
* CW-08: public transit between two city walks on one day. The leg runs from
|
||||
* where the first walk really ends to where the next one starts, and departs
|
||||
* when the first one ends: its start that day and its planned total, a total
|
||||
* entered by hand before the one worked out. Where that cannot be worked out
|
||||
* no time is made up. The walks stay two walks on foot; nothing here is saved.
|
||||
*/
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
/** Closer than this, in degrees, a leg's end is the walk's end: the route copies the coordinates. */
|
||||
const SAME_SPOT_DEG = 1e-9;
|
||||
|
||||
/** Which tour a place is, as the trip's tour list knows it. */
|
||||
export type TourOf = (placeId: number) => TourListItem | undefined;
|
||||
|
||||
/** What the transit search says of the two walks a leg runs between. */
|
||||
export interface WalkTransitContext {
|
||||
/** The day both walks are on: on another day the search no longer speaks of them. */
|
||||
dayId: number;
|
||||
fromWalk: string;
|
||||
toWalk: string;
|
||||
/** `HH:MM` the first walk ends that day; null where it cannot be worked out or ends on the next day. */
|
||||
ends: string | null;
|
||||
/** The first walk ends after midnight, on another day than the one searched on. */
|
||||
endsNextDay: boolean;
|
||||
/** The end leaves out a time not known yet, so the walk ends then at the earliest. */
|
||||
endsAtLeast: boolean;
|
||||
/** `HH:MM` the next walk starts that day; null without a start time. */
|
||||
nextStarts: string | null;
|
||||
}
|
||||
|
||||
type TourItem = Extract<DayItineraryItem, { kind: 'tour' }>;
|
||||
|
||||
/** A walk in the day's route: its item there and the tour it is. */
|
||||
interface DayWalk {
|
||||
item: TourItem;
|
||||
tour: TourListItem;
|
||||
}
|
||||
|
||||
/** The two walks a leg runs between. */
|
||||
export interface WalkPair {
|
||||
first: DayWalk;
|
||||
next: DayWalk;
|
||||
}
|
||||
|
||||
/** A walk the arrival comes too late for: its name and the `HH:MM` it starts. */
|
||||
export interface WalkLate {
|
||||
walk: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
function sameSpot(point: { lat: number; lng: number } | null, other: { lat: number; lng: number }): boolean {
|
||||
return !!point && Math.abs(point.lat - other.lat) < SAME_SPOT_DEG && Math.abs(point.lng - other.lng) < SAME_SPOT_DEG;
|
||||
}
|
||||
|
||||
function walkOf(item: DayItineraryItem | undefined, tourOf: TourOf): DayWalk | null {
|
||||
if (item?.kind !== 'tour') return null;
|
||||
const tour = tourOf(item.placeId);
|
||||
return tour?.tour_type === 'walk' ? { item, tour } : null;
|
||||
}
|
||||
|
||||
/** Whether the day's route goes to an item: in the route, and a walk or a stop with a location. */
|
||||
function routeGoesTo(item: DayItineraryItem): boolean {
|
||||
const { assignment } = item;
|
||||
if (assignment.route_excluded || assignment.accommodation_id != null) return false;
|
||||
return item.kind === 'tour' || (!!assignment.place?.lat && !!assignment.place?.lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* The walk a leg leaves from its end and the walk it reaches at its start, or
|
||||
* null where the leg is no such leg. The leg is told by the entry it leaves,
|
||||
* as the connector carries it, since two walks of a day can end at one spot;
|
||||
* it reaches the next point the route goes to, so that must be the next walk.
|
||||
*/
|
||||
export function walkPairAt(
|
||||
items: readonly DayItineraryItem[],
|
||||
leg: Pick<TransitLeg, 'from' | 'to'>,
|
||||
tourOf: TourOf,
|
||||
originId: number | undefined
|
||||
): WalkPair | null {
|
||||
const index = items.findIndex((item) => item.assignment.id === originId);
|
||||
const first = walkOf(items[index], tourOf);
|
||||
if (!first || !sameSpot(first.item.end, leg.from)) return null;
|
||||
const next = walkOf(items.slice(index + 1).find(routeGoesTo), tourOf);
|
||||
return next && sameSpot(next.item.start, leg.to) ? { first, next } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a walk ends after a start, in minutes after midnight, or null without
|
||||
* a start or a total. Past midnight it ends on the next day. A list that says
|
||||
* nothing of the stops leaves the stays out, so that end is a lower bound too.
|
||||
*/
|
||||
export function walkEndOf(
|
||||
start: number | null,
|
||||
tour: TourListItem
|
||||
): { minutes: number; atLeast: boolean; nextDay: boolean } | null {
|
||||
const times = tourPlannedTimes(tour);
|
||||
if (start === null || times.plannedTotalMinutes === null) return null;
|
||||
const minutes = start + times.plannedTotalMinutes;
|
||||
return {
|
||||
minutes,
|
||||
atLeast: times.atLeast || (!times.manuallyOverridden && times.walk === null),
|
||||
nextDay: minutes >= DAY_MINUTES,
|
||||
};
|
||||
}
|
||||
|
||||
/** A stop as named: its own name, else the trip place it stands for. */
|
||||
function stopName(stop: TourPlannerWaypoint | undefined, places: readonly Pick<Place, 'id' | 'name'>[]): string {
|
||||
if (!stop) return '';
|
||||
return stop.name || places.find((place) => place.id === stop.linkedPlaceId)?.name || '';
|
||||
}
|
||||
|
||||
export interface WalkTransitInput {
|
||||
leg: TransitLeg;
|
||||
dayId: number;
|
||||
pair: WalkPair;
|
||||
/** Each walk's stops as read; null where they could not be read. */
|
||||
stops: { first: readonly TourPlannerWaypoint[] | null; next: readonly TourPlannerWaypoint[] | null };
|
||||
places: readonly Pick<Place, 'id' | 'name'>[];
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The search for a leg between two walks: from the first walk's last stop to
|
||||
* the next one's first, each named, else said as the end or start of its walk,
|
||||
* at the coordinates the day's route draws, so a journey saved there joins it.
|
||||
* It departs when the first walk ends, and never at the time the leg's own
|
||||
* point carries: that is when the walk starts.
|
||||
*/
|
||||
export function walkTransitLeg({ leg, dayId, pair, stops, places, t }: WalkTransitInput): TransitLeg & {
|
||||
walk: WalkTransitContext;
|
||||
} {
|
||||
const { first, next } = pair;
|
||||
const firstStops = stops.first ?? [];
|
||||
// A loop ends where it starts, at its first stop again.
|
||||
const endStop = first.item.loop ? firstStops[0] : firstStops[firstStops.length - 1];
|
||||
const end = walkEndOf(walkStartOf(first.item.assignment), first.tour);
|
||||
// Past midnight no time on the search's day is made up.
|
||||
const ends = end && !end.nextDay ? clockOf(end.minutes) : null;
|
||||
const nextStart = walkStartOf(next.item.assignment);
|
||||
return {
|
||||
from: { ...leg.from, name: stopName(endStop, places) || t('tours.walk.transit.endOf', { walk: first.tour.name }) },
|
||||
to: {
|
||||
...leg.to,
|
||||
name: stopName(stops.next?.[0], places) || t('tours.walk.transit.startOf', { walk: next.tour.name }),
|
||||
},
|
||||
time: ends,
|
||||
walk: {
|
||||
dayId,
|
||||
fromWalk: first.tour.name,
|
||||
toWalk: next.tour.name,
|
||||
ends,
|
||||
endsNextDay: end?.nextDay ?? false,
|
||||
endsAtLeast: end?.atLeast ?? false,
|
||||
nextStarts: nextStart === null ? null : clockOf(nextStart),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an arrival comes after a start on a day: a later date always, the
|
||||
* same date by the clock. An arrival without a date is on that day. Nothing is
|
||||
* said without a date, a start or an arrival time.
|
||||
*/
|
||||
export function arrivesAfterStart(
|
||||
arrival: { date: string | null; time: string | null },
|
||||
date: string | null,
|
||||
start: string | null
|
||||
): boolean {
|
||||
const startMinutes = minutesOf(start);
|
||||
const arrivalMinutes = minutesOf(arrival.time);
|
||||
if (!date || startMinutes === null || arrivalMinutes === null) return false;
|
||||
const arrivalDate = arrival.date ?? date;
|
||||
return arrivalDate === date ? arrivalMinutes > startMinutes : arrivalDate > date;
|
||||
}
|
||||
|
||||
/** The next walk a connection found in the search arrives too late for, or null. */
|
||||
export function lateForWalk(
|
||||
walk: WalkTransitContext | null,
|
||||
date: string | null,
|
||||
arrival: { date: string | null; time: string | null }
|
||||
): WalkLate | null {
|
||||
if (!walk?.nextStarts || !arrivesAfterStart(arrival, date, walk.nextStarts)) return null;
|
||||
return { walk: walk.toWalk, time: walk.nextStarts };
|
||||
}
|
||||
|
||||
export interface JourneyLateInput {
|
||||
reservation: Pick<Reservation, 'type' | 'reservation_end_time'>;
|
||||
/** The day's items after the journey, in their order. */
|
||||
following: readonly MergedItem[];
|
||||
date: string | null;
|
||||
tourOf: TourOf;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved journey that now arrives after the walk it leads to starts, said
|
||||
* while the plan is looked at and never written: a walk moved or a journey
|
||||
* changed since shows here, and nothing is replaced. Only a public transit
|
||||
* journey, followed by a walk with a start time, notes aside.
|
||||
*/
|
||||
export function journeyLate({ reservation, following, date, tourOf }: JourneyLateInput): WalkLate | null {
|
||||
if (reservation.type !== 'transit') return null;
|
||||
const next = following.find((item) => item.type !== 'note');
|
||||
if (next?.type !== 'place') return null;
|
||||
const assignment: Assignment = next.data;
|
||||
const tour = assignment.tour_place_id === assignment.place_id ? tourOf(assignment.place_id) : undefined;
|
||||
const start = walkStartOf(assignment);
|
||||
if (tour?.tour_type !== 'walk' || start === null) return null;
|
||||
const time = clockOf(start);
|
||||
const arrival = splitReservationDateTime(reservation.reservation_end_time);
|
||||
return arrivesAfterStart(arrival, date, time) ? { walk: tour.name, time } : null;
|
||||
}
|
||||
Reference in New Issue
Block a user