mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +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:
@@ -16,7 +16,7 @@
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
|
||||
@@ -571,6 +571,12 @@
|
||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
|
||||
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2969,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1142,
|
||||
"src/pages/TripPlannerPage.tsx": 1141,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
tl.routeModeOptions,
|
||||
mode => tl.setLegMode(assignmentId, mode),
|
||||
t,
|
||||
transitLeg ? () => tl.planTransitLeg(transitLeg) : undefined,
|
||||
transitLeg ? () => tl.planTransitLeg(transitLeg, assignmentId) : undefined,
|
||||
))
|
||||
}
|
||||
// Plugin time contributions in the day plan (dayScheduleProvider hook) —
|
||||
@@ -242,6 +242,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
drag={dragFor(row)}
|
||||
onToggle={() => tl.toggleTransit(row.key)}
|
||||
onOpenJourney={() => tl.openTransitJourney(row.res)}
|
||||
late={tl.journeyLate(row.item)}
|
||||
/>
|
||||
{dayScheduleFor('reservation', row.res.id)}
|
||||
</Fragment>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { formatScheduleMinutes } from '../../../../components/Plugins/PluginDayS
|
||||
import type { PluginDayScheduleItem } from '../../../../api/client'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import TourDayRowFacts, { TourDayRowIcon } from '../../../../components/Tours/TourDayRowFacts'
|
||||
import { WalkLateLine } from '../../../../components/Tours/walk/WalkTransitNote'
|
||||
import type { WalkLate } from '../../../../components/Tours/walk/walkTransit'
|
||||
|
||||
/**
|
||||
* The five row types of the mobile day timeline (place / manual transport /
|
||||
@@ -389,7 +391,7 @@ function TransitLegRows({ legs, t }: { legs: TransitLegDisplay[]; t: Translation
|
||||
)
|
||||
}
|
||||
|
||||
export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, onToggle, onOpenJourney }: {
|
||||
export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, onToggle, onOpenJourney, late }: {
|
||||
res: TransportEntry
|
||||
transit: TransitMeta
|
||||
dayId: number
|
||||
@@ -399,6 +401,8 @@ export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, o
|
||||
drag?: RowDrag
|
||||
onToggle: () => void
|
||||
onOpenJourney: () => void
|
||||
/** CW-08: the city walk this journey now arrives too late for. */
|
||||
late?: WalkLate | null
|
||||
}) {
|
||||
const { t } = chrome
|
||||
const Icon = RES_ICONS.transit
|
||||
@@ -436,6 +440,7 @@ export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, o
|
||||
<Chevron size={15} strokeWidth={2} className={`flex-none text-m-faint ${start ? '' : 'ms-auto'}`} />
|
||||
</div>
|
||||
<TransitStrip legs={transit.legs} />
|
||||
<WalkLateLine late={late} />
|
||||
</div>
|
||||
{open && <TransitLegRows legs={transit.legs} t={t} />}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,8 @@ import { dayWeatherAnchor } from '../../../../utils/dayWeather'
|
||||
import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder'
|
||||
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds } from '../../../../utils/dayMerge'
|
||||
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute'
|
||||
import { buildTransitLeg, buildTransitNameIndex, type TransitLeg } from '../../../../components/Planner/transitLeg'
|
||||
import type { PlannedTransitLeg, TransitLeg } from '../../../../components/Planner/transitLeg'
|
||||
import { useTransitLegPlanner } from '../../../../components/Planner/useTransitLegPlanner'
|
||||
import {
|
||||
buildPlanRows, breaksChronology, findUpNext, hotelChipsForDay, hotelLegsForDay, itemHasTime,
|
||||
type HotelLegs, type PlanRow, type TransportEntry,
|
||||
@@ -352,26 +353,30 @@ export function useMPlanTimeline(planner: TripPlanner) {
|
||||
|
||||
// ── 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.
|
||||
// the leg's two ends and the time the stop it leaves is planned for, between two city
|
||||
// walks from the first one's end (CW-08). 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)
|
||||
const openTransitSearch = useCallback(({ dayId, ...prefill }: PlannedTransitLeg) => {
|
||||
planner.setTransportModalDayId(dayId)
|
||||
planner.setEditingTransport(null)
|
||||
planner.setTransitPrefill(leg)
|
||||
planner.setTransitPrefill(prefill)
|
||||
planner.setTransportModalAutomated(true)
|
||||
planner.setShowTransportModal(true)
|
||||
}, [planner, day])
|
||||
}, [planner])
|
||||
const tourOf = useCallback((placeId: number) => planner.tours.find(tour => tour.place_id === placeId), [planner.tours])
|
||||
const { legFor, plan, lateBefore } = useTransitLegPlanner({
|
||||
tripId, assignments, accommodations: tripAccommodations, reservations, places: planner.places,
|
||||
toursEnabled: planner.toursEnabled, tourOf, onPlan: tripHasDates ? openTransitSearch : undefined,
|
||||
})
|
||||
const transitLegFor = useCallback((seg: RouteSegment): TransitLeg | null => (day ? legFor(day.id, seg) : null), [day, legFor])
|
||||
// `originId` is the stop the leg leaves, which tells two walks ending at one spot apart.
|
||||
const planTransitLeg = useCallback((leg: TransitLeg, originId: number) => {
|
||||
if (day) plan(day.id, leg, originId)
|
||||
}, [day, plan])
|
||||
// A saved journey that now arrives after the walk it leads to starts (CW-08).
|
||||
const journeyLate = useCallback((item: MergedItem) =>
|
||||
(day ? lateBefore(day, item.data, merged.slice(merged.indexOf(item) + 1)) : null), [day, lateBefore, merged])
|
||||
|
||||
return {
|
||||
day, rows, hotelLegs, merged, hotelChips, weather, weatherTemp, upNext,
|
||||
@@ -382,7 +387,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
|
||||
moveRowTo,
|
||||
addPlace, addBooking, addTransport,
|
||||
optimize, canExportRoute, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf,
|
||||
routeModeOptions, setLegMode, transitLegFor, planTransitLeg,
|
||||
routeModeOptions, setLegMode, transitLegFor, planTransitLeg, journeyLate,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
initialTime={transitPrefill?.time ?? null}
|
||||
walk={transitPrefill?.walk ?? null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -379,7 +379,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined}
|
||||
onOpenTransit={(r) => setTransitJourney(r)}
|
||||
onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
|
||||
onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
|
||||
onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, ...leg }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(leg); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
|
||||
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
|
||||
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
|
||||
// The narrow layout keeps what a booking row opened before.
|
||||
@@ -617,7 +617,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} plannedTourDurations={plannedTourDurations} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} plannedTourDurations={plannedTourDurations} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, ...leg }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(leg); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} toursEnabled={toursEnabled} excludePlaceIds={toursEnabled ? tourPlaceIds : undefined} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { airtrailApi } from '../../api/client'
|
||||
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
||||
import type { TransitPrefill } from '../../components/Planner/transitLeg'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
import type { PlannerBase, PlannerSearchParams } from './plannerTypes'
|
||||
@@ -128,7 +129,7 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) {
|
||||
// Public transit (#1065): open the TransportModal in its Automated mode, seed
|
||||
// the search (change-route), and show the journey view for a saved entry.
|
||||
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
|
||||
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null; time?: string | null } | null>(null)
|
||||
const [transitPrefill, setTransitPrefill] = useState<TransitPrefill | null>(null)
|
||||
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
|
||||
// The booking whose detail is open over the desktop plan: a day row, a rental pill,
|
||||
// the inspector's booking card, a map endpoint or the road-trip rail opened it.
|
||||
|
||||
@@ -123,6 +123,7 @@ function buildTl(over: Record<string, unknown> = {}): MPlanTimelineController {
|
||||
setLegMode: vi.fn(),
|
||||
transitLegFor: vi.fn(() => null),
|
||||
planTransitLeg: vi.fn(),
|
||||
journeyLate: vi.fn(() => null),
|
||||
...over,
|
||||
} as unknown as MPlanTimelineController
|
||||
}
|
||||
@@ -331,6 +332,13 @@ describe('MPlanTimeline', () => {
|
||||
expect(screen.getByText('transit.min:15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-014b: a transit row says when it now arrives after the next city walk starts (CW-08)', () => {
|
||||
renderTimeline({ journeyLate: vi.fn(() => ({ walk: 'Harbour', time: '13:00' })) })
|
||||
|
||||
expect(mocks.tl.journeyLate).toHaveBeenCalledWith(expect.objectContaining({ type: 'transport' }))
|
||||
expect(screen.getByTestId('transit-walk-late')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-015: a note row opens the note sheet with its note in edit mode', () => {
|
||||
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
@@ -427,7 +435,7 @@ describe('MPlanTimeline', () => {
|
||||
expect(mocks.tl.transitLegFor).toHaveBeenCalledWith(SEG)
|
||||
fireEvent.click(screen.getByText('transit.title'))
|
||||
|
||||
expect(mocks.tl.planTransitLeg).toHaveBeenCalledWith(leg)
|
||||
expect(mocks.tl.planTransitLeg).toHaveBeenCalledWith(leg, 11)
|
||||
expect(mocks.tl.setLegMode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -375,6 +375,14 @@ describe('TransitRow', () => {
|
||||
|
||||
expect(onOpenJourney).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-026b: a journey that now arrives after the next city walk starts says so under its strip (CW-08)', () => {
|
||||
const { rerender } = render(<TransitRow {...base} open={false} late={{ walk: 'Harbour', time: '13:00' }} />)
|
||||
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00')
|
||||
|
||||
rerender(<TransitRow {...base} open={false} late={null} />)
|
||||
expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConnRow', () => {
|
||||
|
||||
@@ -80,15 +80,17 @@ vi.mock('../../../../src/components/Planner/LocationSelect', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({
|
||||
default: ({ day, places, onAdd, initialFrom, initialTime }: {
|
||||
default: ({ day, places, onAdd, initialFrom, initialTime, walk }: {
|
||||
day: { id: number }
|
||||
places: { name: string }[]
|
||||
onAdd: (p: Record<string, unknown>) => void
|
||||
initialFrom: { name: string } | null
|
||||
initialTime: string | null
|
||||
walk: { fromWalk: string } | null
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="transit-day">{day.id}</span>
|
||||
<span data-testid="transit-walk">{walk?.fromWalk ?? ''}</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>
|
||||
@@ -964,6 +966,17 @@ describe('MTransportFormSheet', () => {
|
||||
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')
|
||||
expect(screen.getByTestId('transit-walk')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-039b: a leg between two city walks hands the panel what it says of them (CW-08)', () => {
|
||||
const walk = { dayId: 12, fromWalk: 'Old town', toWalk: 'Harbour', ends: null, endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' }
|
||||
renderSheet(makePlanner({
|
||||
transportModalAutomated: true,
|
||||
transportModalDayId: 12,
|
||||
transitPrefill: { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: null, time: null, walk },
|
||||
}))
|
||||
expect(screen.getByTestId('transit-walk')).toHaveTextContent('Old town')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-040: the transit panel saves its itinerary through the shared save path', async () => {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, renderHook } from '../../../helpers/render'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { act, renderHook, waitFor } from '../../../helpers/render'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../helpers/factories'
|
||||
import { buildAssignment, buildDay, buildPlace, buildReservation, buildTrip } from '../../../helpers/factories'
|
||||
import { useMPlanTimeline } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
|
||||
import { tourRepo } from '../../../../src/repo/tourRepo'
|
||||
import type { RouteSegment } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-PLTL-HOOK-001 to FE-MOB-PLTL-HOOK-002: public transit for one leg (#2398)
|
||||
// FE-MOB-PLTL-HOOK-001 to FE-MOB-PLTL-HOOK-004: public transit for one leg (#2398), between two city walks (CW-08)
|
||||
|
||||
vi.mock('../../../../src/repo/tourRepo', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../../../src/repo/tourRepo')>()
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: vi.fn() } }
|
||||
})
|
||||
|
||||
vi.mock('../../../../src/hooks/useRouteCalculation', () => ({
|
||||
useRouteCalculation: () => ({ routeSegments: [] }),
|
||||
@@ -41,7 +48,7 @@ describe('useMPlanTimeline, public transit for one leg', () => {
|
||||
time: '09:30',
|
||||
})
|
||||
|
||||
act(() => result.current.planTransitLeg(leg!))
|
||||
act(() => result.current.planTransitLeg(leg!, 11))
|
||||
|
||||
expect(p.setTransportModalDayId).toHaveBeenCalledWith(5)
|
||||
expect(p.setEditingTransport).toHaveBeenCalledWith(null)
|
||||
@@ -57,3 +64,55 @@ describe('useMPlanTimeline, public transit for one leg', () => {
|
||||
expect(result.current.transitLegFor(SEG)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMPlanTimeline, public transit between two city walks (CW-08)', () => {
|
||||
const oldTown = buildPlace({ id: 51, name: 'Old town', lat: 52.52, lng: 13.4, route_geometry: '[[52.52,13.4],[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 walk = (place: { id: number; name: string }) => ({
|
||||
place_id: place.id, name: place.name, tour_type: 'walk', duration: 90, planned_duration_minutes: null,
|
||||
station_count: 2, station_stay_minutes: 45, station_stays_unknown: 0,
|
||||
}) as TourListItem
|
||||
const walks = {
|
||||
'5': [
|
||||
buildAssignment({ id: 21, day_id: 5, order_index: 0, tour_place_id: 51, place: { ...oldTown, place_time: '10:00' } }),
|
||||
buildAssignment({ id: 22, day_id: 5, order_index: 1, tour_place_id: 52, place: { ...harbour, place_time: '13:00' } }),
|
||||
],
|
||||
}
|
||||
const walkPlanner = (reservations = [] as ReturnType<typeof buildReservation>[]) => buildPlanner({
|
||||
trip: buildTrip({ id: 1, start_date: '2026-05-01', end_date: '2026-05-05' }), days: [DAY], selectedDayId: DAY.id,
|
||||
assignments: walks, reservations, places: [oldTown, harbour], toursEnabled: true, tours: [walk(oldTown), walk(harbour)],
|
||||
} as never)
|
||||
|
||||
it('FE-MOB-PLTL-HOOK-003: opens the search from the last stop to the first, when the first walk ends', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) => Promise.resolve({
|
||||
waypoints: [{ lat: 0, lng: 0, role: 'via', sequence: 0, key: `${placeId}`, name: placeId === 51 ? 'Old bridge' : 'Fish market' }],
|
||||
} as never))
|
||||
const p = walkPlanner()
|
||||
const { result } = renderHook(() => useMPlanTimeline(p))
|
||||
|
||||
const leg = result.current.transitLegFor({ from: [52.51, 13.42], to: [52.5, 13.45] } as unknown as RouteSegment)
|
||||
if (!leg) throw new Error('no leg between the walks')
|
||||
act(() => result.current.planTransitLeg(leg, 21))
|
||||
|
||||
await waitFor(() => expect(p.setTransitPrefill).toHaveBeenCalledWith({
|
||||
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: 5, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' },
|
||||
}))
|
||||
expect(p.setTransportModalDayId).toHaveBeenCalledWith(5)
|
||||
expect(p.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-HOOK-004: says a saved journey arrives after the next walk starts', () => {
|
||||
const journey = buildReservation({
|
||||
id: 300, type: 'transit', title: 'Old bridge → Fish market', day_id: 5,
|
||||
reservation_time: '2026-05-02T12:20', reservation_end_time: '2026-05-02T13:10',
|
||||
})
|
||||
const { result } = renderHook(() => useMPlanTimeline(walkPlanner([journey])))
|
||||
|
||||
const item = result.current.merged.find(it => it.type === 'transport')
|
||||
if (!item) throw new Error('no journey on the day')
|
||||
expect(result.current.journeyLate(item)).toEqual({ walk: 'Harbour', time: '13:00' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -593,6 +593,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'مكان في الرحلة',
|
||||
'tours.walk.hours.sourceOsmHint': 'وُجدت مع المحطة على OpenStreetMap؛ قد تكون قديمة.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'أُدخلت لهذا المكان من الرحلة.',
|
||||
'tours.walk.transit.endOf': 'نهاية جولة {walk}',
|
||||
'tours.walk.transit.startOf': 'بداية جولة {walk}',
|
||||
'tours.walk.transit.ends': 'تنتهي جولة {walk} نحو {time}.',
|
||||
'tours.walk.transit.endsAtLeast': 'تنتهي جولة {walk} في {time} على أقرب تقدير، فبعض أوقاتها غير معروف بعد.',
|
||||
'tours.walk.transit.endUnknown': 'موعد انتهاء جولة {walk} غير معروف بعد، لذا اختر وقت المغادرة.',
|
||||
'tours.walk.transit.endsNextDay': 'تنتهي جولة {walk} بعد منتصف الليل، لذا اختر وقت المغادرة.',
|
||||
'tours.walk.transit.nextStarts': 'تبدأ جولة {walk} في {time}.',
|
||||
'tours.walk.transit.late': 'الوصول بعد بدء جولة {walk} في {time}',
|
||||
'tours.poi.categoriesNone': 'لم تُختر أي فئة',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'نوع الجولة',
|
||||
|
||||
@@ -592,6 +592,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Səyahət yeri',
|
||||
'tours.walk.hours.sourceOsmHint': 'Dayanacaqla birlikdə OpenStreetMap-dən tapılıb; köhnəlmiş ola bilər.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Səyahətin bu yeri üçün daxil edilib.',
|
||||
'tours.walk.transit.endOf': '{walk} sonu',
|
||||
'tours.walk.transit.startOf': '{walk} başlanğıcı',
|
||||
'tours.walk.transit.ends': '{walk} təxminən {time} bitir.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} ən tez {time} bitir; vaxtların hamısı hələ məlum deyil.',
|
||||
'tours.walk.transit.endUnknown': '{walk} gəzintisinin bitmə vaxtı hələ məlum deyil, ona görə yola düşmə vaxtını seç.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} gəzintisi gecə yarısından sonra bitir, ona görə yola düşmə vaxtını seç.',
|
||||
'tours.walk.transit.nextStarts': '{walk} {time} başlayır.',
|
||||
'tours.walk.transit.late': '{walk} {time} başlayır, çatma isə daha gec olur',
|
||||
'tours.poi.categoriesNone': 'Kateqoriya seçilməyib',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tur növü',
|
||||
|
||||
@@ -597,6 +597,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Lugar da viagem',
|
||||
'tours.walk.hours.sourceOsmHint': 'Encontrado com a parada no OpenStreetMap; pode estar desatualizado.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Informado para este lugar da viagem.',
|
||||
'tours.walk.transit.endOf': 'Fim de {walk}',
|
||||
'tours.walk.transit.startOf': 'Início de {walk}',
|
||||
'tours.walk.transit.ends': '{walk} termina por volta das {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} termina não antes das {time}; alguns horários ainda não são conhecidos.',
|
||||
'tours.walk.transit.endUnknown': 'Ainda não se sabe quando {walk} termina, então escolha um horário de saída.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} só termina depois da meia-noite, então escolha um horário de saída.',
|
||||
'tours.walk.transit.nextStarts': '{walk} começa às {time}.',
|
||||
'tours.walk.transit.late': 'Chega depois do início de {walk}, às {time}',
|
||||
'tours.poi.categoriesNone': 'Nenhuma categoria escolhida',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tipo de passeio',
|
||||
|
||||
@@ -598,6 +598,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Lloc del viatge',
|
||||
'tours.walk.hours.sourceOsmHint': 'Trobat amb la parada a OpenStreetMap; pot estar desactualitzat.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Introduït per a aquest lloc del viatge.',
|
||||
'tours.walk.transit.endOf': 'Final de {walk}',
|
||||
'tours.walk.transit.startOf': 'Inici de {walk}',
|
||||
'tours.walk.transit.ends': '{walk} acaba cap a les {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} acaba com a aviat a les {time}; encara no se saben tots els temps.',
|
||||
'tours.walk.transit.endUnknown': 'Encara no se sap quan acaba {walk}, així que tria una hora de sortida.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} acaba després de mitjanit, així que tria una hora de sortida.',
|
||||
'tours.walk.transit.nextStarts': '{walk} comença a les {time}.',
|
||||
'tours.walk.transit.late': 'Arriba després que comenci {walk}, a les {time}',
|
||||
'tours.poi.categoriesNone': 'Cap categoria triada',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tipus de ruta',
|
||||
|
||||
@@ -585,6 +585,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Místo výletu',
|
||||
'tours.walk.hours.sourceOsmHint': 'Nalezeno se zastávkou v OpenStreetMap; může být zastaralé.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Zadáno u tohoto místa výletu.',
|
||||
'tours.walk.transit.endOf': 'Konec procházky {walk}',
|
||||
'tours.walk.transit.startOf': 'Začátek procházky {walk}',
|
||||
'tours.walk.transit.ends': 'Procházka {walk} končí zhruba v {time}.',
|
||||
'tours.walk.transit.endsAtLeast': 'Procházka {walk} končí nejdříve v {time}; zatím nejsou známé všechny časy.',
|
||||
'tours.walk.transit.endUnknown': 'Kdy procházka {walk} končí, zatím není známo, proto vyber čas odjezdu.',
|
||||
'tours.walk.transit.endsNextDay': 'Procházka {walk} končí až po půlnoci, proto vyber čas odjezdu.',
|
||||
'tours.walk.transit.nextStarts': 'Procházka {walk} začíná v {time}.',
|
||||
'tours.walk.transit.late': 'Příjezd až po začátku procházky {walk} v {time}',
|
||||
'tours.poi.categoriesNone': 'Žádná kategorie',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Typ túry',
|
||||
|
||||
@@ -602,6 +602,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Ort der Reise',
|
||||
'tours.walk.hours.sourceOsmHint': 'Mit der Station aus OpenStreetMap übernommen; kann veraltet sein.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Für diesen Ort der Reise eingetragen.',
|
||||
'tours.walk.transit.endOf': 'Ende von {walk}',
|
||||
'tours.walk.transit.startOf': 'Start von {walk}',
|
||||
'tours.walk.transit.ends': '{walk} endet gegen {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} endet frühestens um {time}; noch sind nicht alle Zeiten bekannt.',
|
||||
'tours.walk.transit.endUnknown': 'Wann {walk} endet, ist noch nicht bekannt. Wähle deshalb eine Abfahrtszeit.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} endet erst nach Mitternacht. Wähle deshalb eine Abfahrtszeit.',
|
||||
'tours.walk.transit.nextStarts': '{walk} beginnt um {time}.',
|
||||
'tours.walk.transit.late': 'Ankunft nach dem Start von {walk} um {time}',
|
||||
'tours.poi.categoriesNone': 'Keine Kategorie gewählt',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tourtyp',
|
||||
|
||||
@@ -586,6 +586,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Trip place',
|
||||
'tours.walk.hours.sourceOsmHint': 'Found with the stop on OpenStreetMap; it may be out of date.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Entered for this place of the trip.',
|
||||
'tours.walk.transit.endOf': 'End of {walk}',
|
||||
'tours.walk.transit.startOf': 'Start of {walk}',
|
||||
'tours.walk.transit.ends': '{walk} ends at about {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} ends at {time} at the earliest; not all of its times are known yet.',
|
||||
'tours.walk.transit.endUnknown': 'When {walk} ends is not known yet, so choose a departure time.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} only ends after midnight, so choose a departure time.',
|
||||
'tours.walk.transit.nextStarts': '{walk} starts at {time}.',
|
||||
'tours.walk.transit.late': 'Arrives after {walk} starts at {time}',
|
||||
'tours.poi.categoriesNone': 'No category chosen',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tour type',
|
||||
|
||||
@@ -597,6 +597,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Lugar del viaje',
|
||||
'tours.walk.hours.sourceOsmHint': 'Encontrado con la parada en OpenStreetMap; puede estar desactualizado.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Introducido para este lugar del viaje.',
|
||||
'tours.walk.transit.endOf': 'Final de {walk}',
|
||||
'tours.walk.transit.startOf': 'Inicio de {walk}',
|
||||
'tours.walk.transit.ends': '{walk} termina hacia las {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} termina no antes de las {time}; aún no se conocen todos sus tiempos.',
|
||||
'tours.walk.transit.endUnknown': 'Aún no se sabe cuándo termina {walk}, así que elige una hora de salida.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} termina después de medianoche, así que elige una hora de salida.',
|
||||
'tours.walk.transit.nextStarts': '{walk} empieza a las {time}.',
|
||||
'tours.walk.transit.late': 'Llega después de que empiece {walk}, a las {time}',
|
||||
'tours.poi.categoriesNone': 'Ninguna categoría elegida',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tipo de ruta',
|
||||
|
||||
@@ -588,6 +588,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Reisi koht',
|
||||
'tours.walk.hours.sourceOsmHint': 'Leitud koos peatusega OpenStreetMapist; võib olla aegunud.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Sisestatud selle reisi koha juurde.',
|
||||
'tours.walk.transit.endOf': '{walk} lõpp',
|
||||
'tours.walk.transit.startOf': '{walk} algus',
|
||||
'tours.walk.transit.ends': '{walk} lõpeb umbes kell {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} lõpeb kõige varem kell {time}; kõik ajad pole veel teada.',
|
||||
'tours.walk.transit.endUnknown': 'Millal {walk} lõpeb, pole veel teada, seega vali väljumisaeg.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} lõpeb alles pärast keskööd, seega vali väljumisaeg.',
|
||||
'tours.walk.transit.nextStarts': '{walk} algab kell {time}.',
|
||||
'tours.walk.transit.late': 'Saabub pärast seda, kui {walk} algab kell {time}',
|
||||
'tours.poi.categoriesNone': 'Kategooriat pole valitud',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Matka tüüp',
|
||||
|
||||
@@ -604,6 +604,15 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Lieu du voyage',
|
||||
'tours.walk.hours.sourceOsmHint': "Trouvé avec l'étape sur OpenStreetMap ; peut ne plus être à jour.",
|
||||
'tours.walk.hours.sourcePlaceHint': 'Saisi pour ce lieu du voyage.',
|
||||
'tours.walk.transit.endOf': 'Fin de {walk}',
|
||||
'tours.walk.transit.startOf': 'Début de {walk}',
|
||||
'tours.walk.transit.ends': '{walk} se termine vers {time}.',
|
||||
'tours.walk.transit.endsAtLeast':
|
||||
'{walk} se termine au plus tôt à {time} ; certains temps ne sont pas encore connus.',
|
||||
'tours.walk.transit.endUnknown': 'On ne sait pas encore quand {walk} se termine : choisis une heure de départ.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} se termine après minuit : choisis une heure de départ.',
|
||||
'tours.walk.transit.nextStarts': '{walk} commence à {time}.',
|
||||
'tours.walk.transit.late': 'Arrivée après le début de {walk} à {time}',
|
||||
'tours.poi.categoriesNone': 'Aucune catégorie choisie',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Type de sortie',
|
||||
|
||||
@@ -606,6 +606,16 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Μέρος του ταξιδιού',
|
||||
'tours.walk.hours.sourceOsmHint': 'Βρέθηκε μαζί με τη στάση στο OpenStreetMap. Μπορεί να μην είναι ενημερωμένο.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Καταχωρίστηκε για αυτό το μέρος του ταξιδιού.',
|
||||
'tours.walk.transit.endOf': 'Τέλος της βόλτας {walk}',
|
||||
'tours.walk.transit.startOf': 'Αρχή της βόλτας {walk}',
|
||||
'tours.walk.transit.ends': 'Η βόλτα {walk} τελειώνει γύρω στις {time}.',
|
||||
'tours.walk.transit.endsAtLeast':
|
||||
'Η βόλτα {walk} τελειώνει το νωρίτερο στις {time}, αφού δεν είναι ακόμη γνωστοί όλοι οι χρόνοι.',
|
||||
'tours.walk.transit.endUnknown':
|
||||
'Δεν είναι ακόμη γνωστό πότε τελειώνει η βόλτα {walk}, οπότε διάλεξε ώρα αναχώρησης.',
|
||||
'tours.walk.transit.endsNextDay': 'Η βόλτα {walk} τελειώνει μετά τα μεσάνυχτα, οπότε διάλεξε ώρα αναχώρησης.',
|
||||
'tours.walk.transit.nextStarts': 'Η βόλτα {walk} ξεκινά στις {time}.',
|
||||
'tours.walk.transit.late': 'Άφιξη μετά την έναρξη της βόλτας {walk} στις {time}',
|
||||
'tours.poi.categoriesNone': 'Δεν επιλέχθηκε κατηγορία',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Τύπος διαδρομής',
|
||||
|
||||
@@ -598,6 +598,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Az utazás helye',
|
||||
'tours.walk.hours.sourceOsmHint': 'A megállóval együtt az OpenStreetMapből; elavult lehet.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Az utazás ezen helyéhez rögzítve.',
|
||||
'tours.walk.transit.endOf': '{walk} vége',
|
||||
'tours.walk.transit.startOf': '{walk} kezdete',
|
||||
'tours.walk.transit.ends': '{walk}: a séta kb. {time} órakor ér véget.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk}: a séta legkorábban {time} órakor ér véget; még nem ismert minden időpont.',
|
||||
'tours.walk.transit.endUnknown': '{walk}: a séta vége még nem ismert, ezért válassz indulási időt.',
|
||||
'tours.walk.transit.endsNextDay': '{walk}: a séta csak éjfél után ér véget, ezért válassz indulási időt.',
|
||||
'tours.walk.transit.nextStarts': '{walk}: a séta {time} órakor kezdődik.',
|
||||
'tours.walk.transit.late': 'Érkezés a séta kezdete után: {walk}, {time}',
|
||||
'tours.poi.categoriesNone': 'Nincs kategória kiválasztva',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Túratípus',
|
||||
|
||||
@@ -590,6 +590,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Tempat perjalanan',
|
||||
'tours.walk.hours.sourceOsmHint': 'Ditemukan bersama titik ini di OpenStreetMap; mungkin sudah usang.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Diisi untuk tempat perjalanan ini.',
|
||||
'tours.walk.transit.endOf': 'Akhir {walk}',
|
||||
'tours.walk.transit.startOf': 'Awal {walk}',
|
||||
'tours.walk.transit.ends': '{walk} selesai sekitar pukul {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} selesai paling cepat pukul {time}; belum semua waktunya diketahui.',
|
||||
'tours.walk.transit.endUnknown': 'Belum diketahui kapan {walk} selesai, jadi pilih waktu berangkat.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} baru selesai setelah tengah malam, jadi pilih waktu berangkat.',
|
||||
'tours.walk.transit.nextStarts': '{walk} dimulai pukul {time}.',
|
||||
'tours.walk.transit.late': 'Tiba setelah {walk} dimulai pukul {time}',
|
||||
'tours.poi.categoriesNone': 'Belum ada kategori',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Jenis tur',
|
||||
|
||||
@@ -599,6 +599,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Luogo del viaggio',
|
||||
'tours.walk.hours.sourceOsmHint': 'Trovato con la tappa su OpenStreetMap; potrebbe non essere aggiornato.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Inserito per questo luogo del viaggio.',
|
||||
'tours.walk.transit.endOf': 'Fine di {walk}',
|
||||
'tours.walk.transit.startOf': 'Inizio di {walk}',
|
||||
'tours.walk.transit.ends': '{walk} finisce verso le {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} finisce non prima delle {time}; non tutti i tempi sono ancora noti.',
|
||||
'tours.walk.transit.endUnknown': 'Non si sa ancora quando finisce {walk}, quindi scegli un orario di partenza.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} finisce dopo mezzanotte, quindi scegli un orario di partenza.',
|
||||
'tours.walk.transit.nextStarts': '{walk} inizia alle {time}.',
|
||||
'tours.walk.transit.late': "Arrivo dopo l'inizio di {walk} alle {time}",
|
||||
'tours.poi.categoriesNone': 'Nessuna categoria scelta',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tipo di tour',
|
||||
|
||||
@@ -577,6 +577,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': '旅行の場所',
|
||||
'tours.walk.hours.sourceOsmHint': '立ち寄り先と一緒に OpenStreetMap から取得。古い可能性があります。',
|
||||
'tours.walk.hours.sourcePlaceHint': 'この旅行の場所に入力されたものです。',
|
||||
'tours.walk.transit.endOf': '{walk} の終点',
|
||||
'tours.walk.transit.startOf': '{walk} の起点',
|
||||
'tours.walk.transit.ends': '{walk} は {time} ごろに終わります。',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} の終了は早くても {time} です。まだ分からない時間があります。',
|
||||
'tours.walk.transit.endUnknown': '{walk} がいつ終わるかはまだ分かりません。出発時刻を選んでください。',
|
||||
'tours.walk.transit.endsNextDay': '{walk} は深夜0時を過ぎてから終わります。出発時刻を選んでください。',
|
||||
'tours.walk.transit.nextStarts': '{walk} は {time} に始まります。',
|
||||
'tours.walk.transit.late': '{walk} の開始({time})より後に到着',
|
||||
'tours.poi.categoriesNone': 'カテゴリー未選択',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'ツアーの種類',
|
||||
|
||||
@@ -575,6 +575,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': '여행 장소',
|
||||
'tours.walk.hours.sourceOsmHint': '정류지와 함께 OpenStreetMap에서 가져왔습니다. 오래된 정보일 수 있습니다.',
|
||||
'tours.walk.hours.sourcePlaceHint': '이 여행 장소에 입력된 시간입니다.',
|
||||
'tours.walk.transit.endOf': '{walk} 종료 지점',
|
||||
'tours.walk.transit.startOf': '{walk} 시작 지점',
|
||||
'tours.walk.transit.ends': '{walk}은(는) {time}쯤 끝납니다.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk}은(는) 빨라야 {time}에 끝납니다. 아직 모르는 시간이 있습니다.',
|
||||
'tours.walk.transit.endUnknown': '{walk}이(가) 언제 끝나는지 아직 모릅니다. 출발 시간을 선택하세요.',
|
||||
'tours.walk.transit.endsNextDay': '{walk}은(는) 자정이 지나서 끝납니다. 출발 시간을 선택하세요.',
|
||||
'tours.walk.transit.nextStarts': '{walk}은(는) {time}에 시작합니다.',
|
||||
'tours.walk.transit.late': '{walk} 시작({time}) 이후 도착',
|
||||
'tours.poi.categoriesNone': '선택한 카테고리 없음',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': '투어 유형',
|
||||
|
||||
@@ -599,6 +599,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Plek van de reis',
|
||||
'tours.walk.hours.sourceOsmHint': 'Gevonden met de halte op OpenStreetMap; kan verouderd zijn.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Ingevuld voor deze plek van de reis.',
|
||||
'tours.walk.transit.endOf': 'Einde van {walk}',
|
||||
'tours.walk.transit.startOf': 'Begin van {walk}',
|
||||
'tours.walk.transit.ends': '{walk} eindigt rond {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} eindigt op zijn vroegst om {time}; nog niet alle tijden zijn bekend.',
|
||||
'tours.walk.transit.endUnknown': 'Nog niet bekend wanneer {walk} eindigt, dus kies een vertrektijd.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} eindigt pas na middernacht, dus kies een vertrektijd.',
|
||||
'tours.walk.transit.nextStarts': '{walk} begint om {time}.',
|
||||
'tours.walk.transit.late': 'Aankomst na de start van {walk} om {time}',
|
||||
'tours.poi.categoriesNone': 'Geen categorie gekozen',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Soort tocht',
|
||||
|
||||
@@ -606,6 +606,15 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Miejsce podróży',
|
||||
'tours.walk.hours.sourceOsmHint': 'Znalezione z przystankiem w OpenStreetMap; może być nieaktualne.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Wpisane dla tego miejsca podróży.',
|
||||
'tours.walk.transit.endOf': 'Koniec trasy {walk}',
|
||||
'tours.walk.transit.startOf': 'Początek trasy {walk}',
|
||||
'tours.walk.transit.ends': 'Trasa {walk} kończy się około {time}.',
|
||||
'tours.walk.transit.endsAtLeast':
|
||||
'Trasa {walk} kończy się najwcześniej o {time}; nie wszystkie czasy są jeszcze znane.',
|
||||
'tours.walk.transit.endUnknown': 'Nie wiadomo jeszcze, kiedy kończy się trasa {walk}, więc wybierz godzinę odjazdu.',
|
||||
'tours.walk.transit.endsNextDay': 'Trasa {walk} kończy się dopiero po północy, więc wybierz godzinę odjazdu.',
|
||||
'tours.walk.transit.nextStarts': 'Trasa {walk} zaczyna się o {time}.',
|
||||
'tours.walk.transit.late': 'Przyjazd po rozpoczęciu trasy {walk} o {time}',
|
||||
'tours.poi.categoriesNone': 'Nie wybrano kategorii',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Rodzaj wycieczki',
|
||||
|
||||
@@ -608,6 +608,16 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Место поездки',
|
||||
'tours.walk.hours.sourceOsmHint': 'Найдено вместе с остановкой в OpenStreetMap; может быть устаревшим.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Указано для этого места поездки.',
|
||||
'tours.walk.transit.endOf': 'Конец прогулки {walk}',
|
||||
'tours.walk.transit.startOf': 'Начало прогулки {walk}',
|
||||
'tours.walk.transit.ends': 'Прогулка {walk} заканчивается около {time}.',
|
||||
'tours.walk.transit.endsAtLeast': 'Прогулка {walk} заканчивается не раньше {time}; известно ещё не всё время.',
|
||||
'tours.walk.transit.endUnknown':
|
||||
'Пока неизвестно, когда заканчивается прогулка {walk}, поэтому выбери время отправления.',
|
||||
'tours.walk.transit.endsNextDay':
|
||||
'Прогулка {walk} заканчивается только после полуночи, поэтому выбери время отправления.',
|
||||
'tours.walk.transit.nextStarts': 'Прогулка {walk} начинается в {time}.',
|
||||
'tours.walk.transit.late': 'Прибытие после начала прогулки {walk} в {time}',
|
||||
'tours.poi.categoriesNone': 'Категория не выбрана',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Тип тура',
|
||||
|
||||
@@ -587,6 +587,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Miesto výletu',
|
||||
'tours.walk.hours.sourceOsmHint': 'Nájdené so zastávkou v OpenStreetMap; môže byť zastarané.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Zadané pri tomto mieste výletu.',
|
||||
'tours.walk.transit.endOf': 'Koniec prechádzky {walk}',
|
||||
'tours.walk.transit.startOf': 'Začiatok prechádzky {walk}',
|
||||
'tours.walk.transit.ends': 'Prechádzka {walk} končí približne o {time}.',
|
||||
'tours.walk.transit.endsAtLeast': 'Prechádzka {walk} končí najskôr o {time}; zatiaľ nie sú známe všetky časy.',
|
||||
'tours.walk.transit.endUnknown': 'Zatiaľ nie je známe, kedy prechádzka {walk} končí, preto vyber čas odchodu.',
|
||||
'tours.walk.transit.endsNextDay': 'Prechádzka {walk} končí až po polnoci, preto vyber čas odchodu.',
|
||||
'tours.walk.transit.nextStarts': 'Prechádzka {walk} začína o {time}.',
|
||||
'tours.walk.transit.late': 'Príchod po začiatku prechádzky {walk} o {time}',
|
||||
'tours.poi.categoriesNone': 'Žiadna kategória',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Typ túry',
|
||||
|
||||
@@ -586,6 +586,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Plats på resan',
|
||||
'tours.walk.hours.sourceOsmHint': 'Hittad med stoppet på OpenStreetMap; kan vara inaktuell.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Angiven för den här platsen på resan.',
|
||||
'tours.walk.transit.endOf': 'Slutet av {walk}',
|
||||
'tours.walk.transit.startOf': 'Början av {walk}',
|
||||
'tours.walk.transit.ends': '{walk} slutar runt kl. {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} slutar tidigast kl. {time}; alla tider är inte kända än.',
|
||||
'tours.walk.transit.endUnknown': 'Det är ännu inte känt när {walk} slutar, så välj en avgångstid.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} slutar först efter midnatt, så välj en avgångstid.',
|
||||
'tours.walk.transit.nextStarts': '{walk} börjar kl. {time}.',
|
||||
'tours.walk.transit.late': 'Ankomst efter att {walk} börjar kl. {time}',
|
||||
'tours.poi.categoriesNone': 'Ingen kategori vald',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Turtyp',
|
||||
|
||||
@@ -563,6 +563,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'สถานที่ในทริป',
|
||||
'tours.walk.hours.sourceOsmHint': 'พบพร้อมจุดแวะใน OpenStreetMap ข้อมูลอาจไม่เป็นปัจจุบัน',
|
||||
'tours.walk.hours.sourcePlaceHint': 'ใส่ไว้สำหรับสถานที่นี้ในทริป',
|
||||
'tours.walk.transit.endOf': 'จุดสิ้นสุดของ {walk}',
|
||||
'tours.walk.transit.startOf': 'จุดเริ่มต้นของ {walk}',
|
||||
'tours.walk.transit.ends': '{walk} จบประมาณ {time}',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} จบเร็วที่สุด {time} เพราะยังไม่ทราบเวลาบางส่วน',
|
||||
'tours.walk.transit.endUnknown': 'ยังไม่ทราบว่า {walk} จบเมื่อใด จึงต้องเลือกเวลาออกเดินทางเอง',
|
||||
'tours.walk.transit.endsNextDay': '{walk} จบหลังเที่ยงคืน จึงต้องเลือกเวลาออกเดินทางเอง',
|
||||
'tours.walk.transit.nextStarts': '{walk} เริ่ม {time}',
|
||||
'tours.walk.transit.late': 'ถึงหลังจาก {walk} เริ่มเวลา {time}',
|
||||
'tours.poi.categoriesNone': 'ยังไม่ได้เลือกหมวดหมู่',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'ประเภททริป',
|
||||
|
||||
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
|
||||
// placeholder spec; here only the general keys are.
|
||||
const groups = pluralGroups(Object.keys(english));
|
||||
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
|
||||
const GENERAL_KEY_COUNT = 817;
|
||||
const GENERAL_KEY_COUNT = 825;
|
||||
|
||||
function literalKeys(source: string): string[] {
|
||||
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
@@ -592,6 +592,15 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Seyahat yeri',
|
||||
'tours.walk.hours.sourceOsmHint': "Durakla birlikte OpenStreetMap'ten bulundu; güncel olmayabilir.",
|
||||
'tours.walk.hours.sourcePlaceHint': 'Seyahatin bu yeri için girildi.',
|
||||
'tours.walk.transit.endOf': '{walk} bitişi',
|
||||
'tours.walk.transit.startOf': '{walk} başlangıcı',
|
||||
'tours.walk.transit.ends': '{walk} yaklaşık {time} saatinde biter.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} en erken {time} saatinde biter; tüm süreler henüz bilinmiyor.',
|
||||
'tours.walk.transit.endUnknown':
|
||||
'{walk} yürüyüşünün ne zaman bittiği henüz bilinmiyor, bu yüzden bir kalkış saati seç.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} yürüyüşü gece yarısından sonra bitiyor, bu yüzden bir kalkış saati seç.',
|
||||
'tours.walk.transit.nextStarts': '{walk} {time} saatinde başlar.',
|
||||
'tours.walk.transit.late': 'Varış, {walk} {time} saatinde başladıktan sonra',
|
||||
'tours.poi.categoriesNone': 'Kategori seçilmedi',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Tur türü',
|
||||
|
||||
@@ -603,6 +603,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Місце подорожі',
|
||||
'tours.walk.hours.sourceOsmHint': 'Знайдено разом із зупинкою в OpenStreetMap; може бути застарілим.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Вказано для цього місця подорожі.',
|
||||
'tours.walk.transit.endOf': 'Кінець прогулянки {walk}',
|
||||
'tours.walk.transit.startOf': 'Початок прогулянки {walk}',
|
||||
'tours.walk.transit.ends': 'Прогулянка {walk} закінчується близько {time}.',
|
||||
'tours.walk.transit.endsAtLeast': 'Прогулянка {walk} закінчується не раніше {time}; ще відомий не весь час.',
|
||||
'tours.walk.transit.endUnknown': 'Поки невідомо, коли закінчується прогулянка {walk}, тож вибери час відправлення.',
|
||||
'tours.walk.transit.endsNextDay': 'Прогулянка {walk} закінчується лише після опівночі, тож вибери час відправлення.',
|
||||
'tours.walk.transit.nextStarts': 'Прогулянка {walk} починається о {time}.',
|
||||
'tours.walk.transit.late': 'Прибуття після початку прогулянки {walk} о {time}',
|
||||
'tours.poi.categoriesNone': 'Категорію не вибрано',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Тип туру',
|
||||
|
||||
@@ -585,6 +585,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': 'Địa điểm của chuyến đi',
|
||||
'tours.walk.hours.sourceOsmHint': 'Tìm thấy cùng điểm dừng trên OpenStreetMap; có thể đã cũ.',
|
||||
'tours.walk.hours.sourcePlaceHint': 'Được nhập cho địa điểm này của chuyến đi.',
|
||||
'tours.walk.transit.endOf': 'Điểm cuối của {walk}',
|
||||
'tours.walk.transit.startOf': 'Điểm đầu của {walk}',
|
||||
'tours.walk.transit.ends': '{walk} kết thúc khoảng {time}.',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} kết thúc sớm nhất lúc {time}; vẫn chưa biết hết các mốc thời gian.',
|
||||
'tours.walk.transit.endUnknown': 'Chưa biết khi nào {walk} kết thúc, nên hãy chọn giờ khởi hành.',
|
||||
'tours.walk.transit.endsNextDay': '{walk} kết thúc sau nửa đêm, nên hãy chọn giờ khởi hành.',
|
||||
'tours.walk.transit.nextStarts': '{walk} bắt đầu lúc {time}.',
|
||||
'tours.walk.transit.late': 'Đến sau khi {walk} bắt đầu lúc {time}',
|
||||
'tours.poi.categoriesNone': 'Chưa chọn danh mục',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': 'Loại chuyến',
|
||||
|
||||
@@ -543,6 +543,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': '行程地點',
|
||||
'tours.walk.hours.sourceOsmHint': '隨站點從 OpenStreetMap 取得,可能已過時。',
|
||||
'tours.walk.hours.sourcePlaceHint': '為行程中的這個地點填寫。',
|
||||
'tours.walk.transit.endOf': '{walk} 終點',
|
||||
'tours.walk.transit.startOf': '{walk} 起點',
|
||||
'tours.walk.transit.ends': '{walk} 約 {time} 結束。',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} 最早 {time} 結束;部分時間尚不清楚。',
|
||||
'tours.walk.transit.endUnknown': '尚不清楚 {walk} 何時結束,請選擇出發時間。',
|
||||
'tours.walk.transit.endsNextDay': '{walk} 要到午夜之後才結束,請選擇出發時間。',
|
||||
'tours.walk.transit.nextStarts': '{walk} {time} 開始。',
|
||||
'tours.walk.transit.late': '抵達晚於 {walk} 的開始時間 {time}',
|
||||
'tours.poi.categoriesNone': '未選擇類別',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': '路線類型',
|
||||
|
||||
@@ -544,6 +544,14 @@ const tours: TranslationStrings = {
|
||||
'tours.walk.hours.sourcePlace': '行程地点',
|
||||
'tours.walk.hours.sourceOsmHint': '随站点从 OpenStreetMap 获取,可能已过时。',
|
||||
'tours.walk.hours.sourcePlaceHint': '为行程中的这个地点填写。',
|
||||
'tours.walk.transit.endOf': '{walk} 终点',
|
||||
'tours.walk.transit.startOf': '{walk} 起点',
|
||||
'tours.walk.transit.ends': '{walk} 约 {time} 结束。',
|
||||
'tours.walk.transit.endsAtLeast': '{walk} 最早 {time} 结束;部分时间尚不清楚。',
|
||||
'tours.walk.transit.endUnknown': '尚不清楚 {walk} 何时结束,请选择出发时间。',
|
||||
'tours.walk.transit.endsNextDay': '{walk} 要到午夜之后才结束,请选择出发时间。',
|
||||
'tours.walk.transit.nextStarts': '{walk} {time} 开始。',
|
||||
'tours.walk.transit.late': '到达晚于 {walk} 的开始时间 {time}',
|
||||
'tours.poi.categoriesNone': '未选择类别',
|
||||
'tours.poi.categoriesMore': '{first} +{more}',
|
||||
'tours.planner.tourType': '路线类型',
|
||||
|
||||
Reference in New Issue
Block a user