fix(roadtrip): seat rides and the drive into tomorrow by one rule, and say why a way was not taken

The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.

A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
This commit is contained in:
Maurice
2026-09-23 11:26:59 +02:00
parent ab3a81a9eb
commit 451e6d240b
54 changed files with 1061 additions and 208 deletions
@@ -2571,6 +2571,37 @@ describe('DayPlanSidebar', () => {
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
})
it('FE-PLANNER-DAYPLAN-221: the slot is worked out over the hotel the list hides, so it does not land behind it (#2461)', async () => {
// The hotel a booking put on the day sits across the water and is no row of the list.
// Worked out over Amsterdam alone, the clock closed the day at 1.5: behind the hotel
// for the road trip, which then drove to Newcastle overland before the crossing.
const { reservationsApi } = await import('../../api/client')
const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' })
const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 })
const hotel = buildPlace({ id: 3, name: 'Hotel Newcastle', lat: 54.975, lng: -1.61 })
const ferry = buildReservation({
id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10,
reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null },
],
})
seedStore(useTripStore, { reservations: [ferry] })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [amsterdam, hotel], reservations: [ferry],
accommodations: [{ id: 7, place_id: 3, start_day_id: 10, end_day_id: 11, check_in: null } as never],
assignments: {
'10': [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: amsterdam }),
buildAssignment({ id: 13, day_id: 10, order_index: 1, place: hotel, accommodation_id: 7 } as never),
],
},
})} />)
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
})
it('FE-PLANNER-DAYPLAN-204: a rejected slot write puts the bookings back where the server has them', async () => {
const { reservationsApi } = await import('../../api/client')
vi.mocked(reservationsApi.updatePositions).mockRejectedValueOnce(new Error('offline'))
@@ -35,7 +35,7 @@ import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
getAssignmentReservations, timedSlot, rideSeatKey,
getAssignmentReservations, timedSlot, storedRideSlot,
type MergedItem,
} from '../../utils/dayMerge'
import { withinDriveRange } from '../../utils/geo'
@@ -438,14 +438,15 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
.slice().sort((a, b) => a.order_index - b.order_index)
// Compute initial day_plan_position for a transport based on time
const computeTransportPosition = (r, da) => {
const computeTransportPosition = (r, da, dayId) => {
// A ride that lands today goes where it adds the least road between the same clocks,
// by the rule the list and the road trip seat it with (`rideSeatKey`). This slot is
// stored, so seated by the clock alone a ferry between two untimed stops stayed at
// the end of the day, and the drive went overland before the crossing (#2461).
const seat = rideSeatKey(r, da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })))
if (seat === -Infinity) return Math.min(...da.map(a => a.order_index)) - 0.5
if (seat !== null) return seat + 0.5
// by the rule the road trip seats it with. This slot is stored, so seated by the
// clock alone a ferry between two untimed stops stayed at the end of the day, and the
// drive went overland before the crossing (#2461). Worked out over every stored row of
// the day rather than `da`: the drive stops at the hotel and the service stops the
// list hides, and the slot is read against their order indexes too.
const ride = storedRideSlot(r, assignments[String(dayId)] || [], accommodations, dayId)
if (ride !== null) return ride
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
// Find the last place with time <= transport time
let afterIdx = -1
@@ -469,7 +470,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
)
const positions = sorted.map((r, idx) => ({
id: r.id,
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
day_plan_position: computeTransportPosition(r, da, dayId) + idx * 0.01,
}))
// Mark as initialized immediately to prevent re-entry
for (const p of positions) initedTransportIds.current.add(p.id)
@@ -8,7 +8,7 @@ import type { LegAlternatives } from './useRouteAlternatives'
import { openLeg } from '../../../tests/helpers/legAlternatives'
/**
* FE-ALTBAR-001..012: the ways of driving one leg, offered over the map.
* FE-ALTBAR-001..014: the ways of driving one leg, offered over the map.
*
* Every value shown comes from the same overlay the map draws. Working them out
* here separately is what once made the list label the driven route "Fastest"
@@ -27,6 +27,7 @@ const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay =>
distance: 290_000,
slowerThanQuickest: 0,
otherEngine: false,
engine: 'osrm',
labelBg: '#0a84ff',
at: { lat: 53, lng: 11 },
...over,
@@ -143,19 +144,20 @@ describe('RoadtripAlternativesBar', () => {
expect(onClose).toHaveBeenCalled()
})
it('FE-ALTBAR-010: an offer the other engine timed says so, and the rest do not', () => {
it('FE-ALTBAR-010: an offer the other engine timed says so, naming that engine, and the rest do not', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[
overlay({ note: 'Fastest' }),
overlay({ index: 1, note: 'No tolls', otherEngine: true }),
overlay({ index: 1, note: 'No tolls', otherEngine: true, engine: 'valhalla' }),
]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getAllByLabelText(/avoidance router/i)).toHaveLength(1)
expect(screen.queryByLabelText(/main router/i)).toBeNull()
})
it('FE-ALTBAR-011: while a choice is checked and saved the list stands still, and the one in hand says so', () => {
@@ -171,7 +173,7 @@ describe('RoadtripAlternativesBar', () => {
/>,
)
const [, current, checked, other] = screen.getAllByRole('button')
expect([current, checked, other].every(chip => (chip as HTMLButtonElement).disabled)).toBe(true)
expect([current, checked, other].every(chip => chip.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(checked).toHaveAttribute('aria-busy', 'true')
expect(other).toHaveAttribute('aria-busy', 'false')
expect(checked.parentElement).toHaveAttribute('aria-busy', 'true')
@@ -186,7 +188,44 @@ describe('RoadtripAlternativesBar', () => {
<RoadtripAlternativesBar open={leg()} overlays={[overlay({ index: 0 }), overlay({ index: 1 })]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
const chips = screen.getAllByRole('button').slice(1)
expect(chips.every(chip => !(chip as HTMLButtonElement).disabled)).toBe(true)
expect(chips.every(chip => chip.getAttribute('aria-disabled') === 'false')).toBe(true)
expect(chips[0].parentElement).toHaveAttribute('aria-busy', 'false')
})
it('FE-ALTBAR-013: a keyboard keeps its place on the chip it chose while the check runs', () => {
// A disabled button hands its focus to the page, so Enter on a chip left the keyboard
// nowhere in the bar that was still open for another choice.
const onChoose = vi.fn()
const overlays = [overlay({ index: 0, note: 'Current' }), overlay({ index: 1 })]
const { rerender } = render(
<RoadtripAlternativesBar open={leg()} overlays={overlays} onChoose={onChoose} onClose={vi.fn()} />,
)
const chip = screen.getAllByRole('button')[2]
chip.focus()
fireEvent.click(chip)
expect(onChoose).toHaveBeenCalledWith(1)
rerender(<RoadtripAlternativesBar open={leg({ proving: 1 })} overlays={overlays} onChoose={onChoose} onClose={vi.fn()} />)
expect(document.activeElement).toBe(chip)
rerender(<RoadtripAlternativesBar open={leg({ notice: 'Not saved.' })} overlays={overlays} onChoose={onChoose} onClose={vi.fn()} />)
expect(document.activeElement).toBe(chip)
})
it('FE-ALTBAR-014: the bar says a check is running and why the last one saved nothing, in a live line', () => {
const overlays = [overlay({ index: 0, note: 'Current' }), overlay({ index: 1 })]
const { rerender } = render(
<RoadtripAlternativesBar open={leg()} overlays={overlays} onChoose={vi.fn()} onClose={vi.fn()} />,
)
// There before it has anything to say, so a screen reader is listening when it does.
const status = screen.getByRole('status')
expect(status).toBeEmptyDOMElement()
expect(status).toHaveClass('sr-only')
rerender(<RoadtripAlternativesBar open={leg({ proving: 1 })} overlays={overlays} onChoose={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByRole('status')).toHaveTextContent(/checking this way/i)
const notice = 'The road trip’s router won’t follow this way, so it was not saved.'
rerender(<RoadtripAlternativesBar open={leg({ notice })} overlays={overlays} onChoose={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByRole('status')).toHaveTextContent(notice)
expect(screen.getByRole('status')).not.toHaveClass('sr-only')
})
})
@@ -5,7 +5,7 @@ import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import type { LegAlternatives } from './useRouteAlternatives'
import { alternativesBusy, alternativesPhase, alternativeSubline, type AlternativeOverlay } from './alternativeOverlays'
import { alternativesBusy, alternativesPhase, alternativeSubline, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
interface RoadtripAlternativesBarProps {
open: LegAlternatives | null
@@ -38,6 +38,10 @@ export default function RoadtripAlternativesBar({
// While a choice is checked against the router and written, the list stands still: a
// second click would race the first, and its answer could land on the leg after it.
const busy = alternativesBusy(open)
// What the check is doing, and why the last one saved nothing. A toast says the latter
// too, but it is announced to nobody and gone in seconds, while this line sits beside
// the chips it is about.
const status = busy ? t('roadtrip.alt.checking') : (open.notice ?? '')
return (
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
@@ -68,53 +72,60 @@ export default function RoadtripAlternativesBar({
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
) : (
<div className="flex flex-wrap gap-1.5" aria-busy={busy}>
{overlays.map(alt => (
<button
key={alt.index}
type="button"
disabled={busy}
aria-busy={open.proving === alt.index}
onClick={() => onChoose(alt.index)}
onMouseEnter={() => onHighlight?.(alt.index)}
onMouseLeave={() => onHighlight?.(null)}
onFocus={() => onHighlight?.(alt.index)}
onBlur={() => onHighlight?.(null)}
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint disabled:cursor-default disabled:opacity-60 disabled:hover:border-edge"
>
{open.proving === alt.index ? (
<Loader2 size={10} className="shrink-0 animate-spin text-content-faint" aria-hidden />
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
// theme-lint-disable: the very colour this route is drawn in on the map;
// a token here would break the one link between list and picture.
style={{ background: alt.color }}
aria-hidden
/>
)}
<span className="flex flex-col">
<span className="text-caption font-medium tabular-nums text-content">
{formatDistance(alt.distance / 1000, distanceUnit)}
{overlays.map(alt => {
const note = otherEngineNote(alt)
return (
<button
key={alt.index}
type="button"
// Refused rather than disabled: a disabled button gives its focus up, and a
// keyboard that chose it would land on the page with the check still running.
aria-disabled={busy}
aria-busy={open.proving === alt.index}
onClick={() => { if (!busy) onChoose(alt.index) }}
onMouseEnter={() => onHighlight?.(alt.index)}
onMouseLeave={() => onHighlight?.(null)}
onFocus={() => onHighlight?.(alt.index)}
onBlur={() => onHighlight?.(null)}
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint aria-disabled:cursor-default aria-disabled:opacity-60 aria-disabled:hover:border-edge"
>
{open.proving === alt.index ? (
<Loader2 size={10} className="shrink-0 animate-spin text-content-faint" aria-hidden />
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
// theme-lint-disable: the very colour this route is drawn in on the map;
// a token here would break the one link between list and picture.
style={{ background: alt.color }}
aria-hidden
/>
)}
<span className="flex flex-col">
<span className="text-caption font-medium tabular-nums text-content">
{formatDistance(alt.distance / 1000, distanceUnit)}
</span>
<span className="text-caption tabular-nums text-content-muted">
{alternativeSubline(alt, time => t('roadtrip.alt.slower', { time }))}
</span>
</span>
<span className="text-caption tabular-nums text-content-muted">
{alternativeSubline(alt, time => t('roadtrip.alt.slower', { time }))}
</span>
</span>
{/* Only where the drive time on the map came from the other engine. The
road and the length are the same question either way; the time is not,
and two engines' times sitting next to each other on the map invite a
subtraction that means nothing. Saying so here, where the choice is
actually made, is cheaper than a second request per offer to restate
the whole list in one engine's terms. */}
{alt.otherEngine ? (
<Tooltip label={t('roadtrip.alt.otherEngine')}>
<Info size={12} className="shrink-0 text-content-faint" aria-label={t('roadtrip.alt.otherEngine')} />
</Tooltip>
) : null}
</button>
))}
{/* Only where the drive time on the map came from the other engine. The
road and the length are the same question either way; the time is not,
and two engines' times sitting next to each other on the map invite a
subtraction that means nothing. Saying so here, where the choice is
actually made, is cheaper than a second request per offer to restate
the whole list in one engine's terms. */}
{note ? (
<Tooltip label={t(note)}>
<Info size={12} className="shrink-0 text-content-faint" aria-label={t(note)} />
</Tooltip>
) : null}
</button>
)
})}
</div>
)}
{/* Kept in the tree while empty, so a screen reader is listening before it speaks. */}
<p role="status" className={status ? 'text-caption text-content-muted' : 'sr-only'}>{status}</p>
</div>
)
}
@@ -1418,7 +1418,7 @@ describe('RoadtripSidebar with the drive in from the day before (#2461)', () =>
wrap(<RoadtripSidebar routes={routes()} reservations={reservations} onOpenBooking={onOpenBooking} />)
const notice = screen.getByRole('status')
expect(within(notice).getByText('IJmuiden to Newcastle has no date, so the drive does not use it.')).toBeInTheDocument()
expect(within(notice).getByText('IJmuiden to Newcastle is on none of this trip’s days, so the drive does not use it.')).toBeInTheDocument()
expect(within(notice).getAllByRole('listitem')).toHaveLength(1)
fireEvent.click(within(notice).getByRole('button', { name: 'Open booking' }))
expect(onOpenBooking).toHaveBeenCalledWith(70)
@@ -1426,7 +1426,7 @@ describe('RoadtripSidebar with the drive in from the day before (#2461)', () =>
it('FE-ROADTRIP-SIDEBAR-063: says so without a button where bookings cannot be opened, and says nothing when every ride has a day', () => {
const reader = wrap(<RoadtripSidebar routes={routes()} reservations={[ferry({})]} />)
expect(screen.getByText('IJmuiden to Newcastle has no date, so the drive does not use it.')).toBeInTheDocument()
expect(screen.getByText('IJmuiden to Newcastle is on none of this trip’s days, so the drive does not use it.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open booking' })).toBeNull()
reader.unmount()
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest'
import { alternativeSubline, alternativesBusy, alternativesPhase, buildAlternativeOverlays, type AlternativeOverlay } from './alternativeOverlays'
import { alternativeSubline, alternativesBusy, alternativesPhase, buildAlternativeOverlays, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
import { formatDurationShort } from './roadtripModel'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator'
/**
* FE-ALTOVL-001..021: turning the router's answers into something drawable.
* FE-ALTOVL-001..023: turning the router's answers into something drawable.
*
* Two decisions live here and neither is cosmetic: which of the offered roads is
* drawn as the one you are on, and where each label hangs. A label anchored on a
@@ -309,6 +309,47 @@ describe('alternativeSubline', () => {
})
})
describe('otherEngineNote', () => {
it('FE-ALTOVL-022: a route another engine timed is named after that engine, one the rail timed not at all', () => {
// On a plugin leg the offers are OSRM's. They used to be called the avoidance
// router's, an engine that had nothing to do with them.
const plugin = buildAlternativeOverlays(
[
alt({ coordinates: line(20, 0), current: true, engine: 'plugin' }),
alt({ coordinates: line(20, 1) }),
alt({ coordinates: line(20, 2), engine: 'valhalla', avoids: 'motorway' }),
],
LABELS,
'plugin',
)
expect(plugin.map(o => o.engine)).toEqual(['plugin', 'osrm', 'valhalla'])
expect(plugin.map(otherEngineNote)).toEqual([null, 'roadtrip.alt.otherEngineStandard', 'roadtrip.alt.otherEngine'])
// On a trip that avoids nothing, the avoidance router's offer is the other one.
const plain = buildAlternativeOverlays(
[alt({ coordinates: line(20, 0), current: true }), alt({ coordinates: line(20, 1), engine: 'valhalla' })],
LABELS,
)
expect(plain.map(otherEngineNote)).toEqual([null, 'roadtrip.alt.otherEngine'])
})
it('FE-ALTOVL-023: a line OSRM drew in the avoidance router’s place heads the list as the other engine’s', () => {
const out = buildAlternativeOverlays(
[
alt({ coordinates: line(20, 0), current: true, engine: 'osrm' }),
alt({ coordinates: line(20, 1), engine: 'valhalla' }),
],
LABELS,
'valhalla',
)
expect(out.map(otherEngineNote)).toEqual(['roadtrip.alt.otherEngineStandard', null])
// Nothing is measured against it: the offer is the quickest the rail's engine timed.
expect(out[1].note).toBe('Fastest')
expect(out.every(o => o.slowerThanQuickest === 0)).toBe(true)
// An engine no offer comes from has nothing to say.
expect(otherEngineNote({ otherEngine: true, engine: 'plugin' })).toBeNull()
})
})
describe('alternativesBusy', () => {
it('FE-ALTOVL-021: a picker is busy exactly while one of its offers is being checked', () => {
expect(alternativesBusy(null)).toBe(false)
@@ -24,6 +24,8 @@ export interface AlternativeOverlay {
* `slowerThanQuickest` stays zero.
*/
otherEngine: boolean
/** The engine that priced this route, which names the other engine when it is one. */
engine: RouteEngine
labelBg: string
/** Where to hang the label — a point on this route and on no other. */
at: { lat: number; lng: number }
@@ -157,6 +159,7 @@ export function buildAlternativeOverlays(
? 0
: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
otherEngine,
engine: engineOf(route),
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
at: at ?? { lat: 0, lng: 0 },
}
@@ -203,6 +206,22 @@ export function alternativeSubline(alt: AlternativeOverlay, slower: (time: strin
return slower(formatDurationShort(alt.slowerThanQuickest))
}
/**
* What a route another engine timed says about it, as a translation key: which engine it
* was. Null for a route the rail's own engine timed.
*
* Both bars used to call every such route the avoidance router's. A leg a route provider
* plugin drives is offered OSRM's ways, and a leg OSRM drew while the avoidance router did
* not answer heads its list with OSRM's line, so the note named an engine that had nothing
* to do with either. One reading for the desk and the phone.
*/
export function otherEngineNote(alt: Pick<AlternativeOverlay, 'otherEngine' | 'engine'>): string | null {
if (!alt.otherEngine) return null
if (alt.engine === 'valhalla') return 'roadtrip.alt.otherEngine'
// No offer comes from a plugin: only the rail's own line on a plugin leg is one's.
return alt.engine === 'osrm' ? 'roadtrip.alt.otherEngineStandard' : null
}
/**
* Whether a choice is being checked and saved, so neither bar takes another one meanwhile.
* One reading for the desk and the phone, which both stand still while it runs.
@@ -1,10 +1,10 @@
import { describe, it, expect, vi } from 'vitest'
import { MAX_PINS, pinAlternative, railDriveOn, railLegAt } from './alternativePins'
import { MAX_PINS, pinAlternative, railDriveOn, railLegAt, refusalHint } from './alternativePins'
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { OfferedRoute } from './useRouteAlternatives'
/**
* FE-ALTPIN-001..011: proving a choice with the rail's own router before it is saved.
* FE-ALTPIN-001..012: proving a choice with the rail's own router before it is saved.
*
* A choice used to be one via at the point where the offer strayed furthest, written
* without asking whether the router then drove the offer. On a ferry it did not (OSRM
@@ -182,3 +182,24 @@ describe('railDriveOn', () => {
expect(railDriveOn(card({ legLines: undefined }), { kind: 'leg', index: 0 })?.line).toBeUndefined()
})
})
describe('refusalHint', () => {
/** Echoes the key with its values, so a case reads which sentence was picked and with what. */
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
it('FE-ALTPIN-012: a refused way says what would get it driven: a ferry booking, or the class left out avoided', () => {
// The ferry the router would not board wins: the crossing is a booking, whatever else
// the way leaves out.
expect(refusalHint({ hasFerry: true, avoids: 'motorway' }, { hasFerry: false }, t)).toBe('roadtrip.alt.ferryNotHeld')
// A way without the motorway on a trip that does not avoid it is held for a few pins
// at most; ticking the class is what drives it.
expect(refusalHint({ avoids: 'motorway' }, { hasFerry: false }, t))
.toBe('roadtrip.alt.avoidNotHeld(class=roadtrip.avoid.motorway,setting=roadtrip.avoid.section)')
expect(refusalHint({ avoids: 'toll' }, {}, t))
.toBe('roadtrip.alt.avoidNotHeld(class=roadtrip.avoid.toll,setting=roadtrip.avoid.section)')
// A plain way, or a ferry the router did board, has no way out to offer.
expect(refusalHint({}, {}, t)).toBeNull()
expect(refusalHint({ hasFerry: true }, { hasFerry: true }, t)).toBeNull()
})
})
@@ -84,6 +84,25 @@ export async function pinAlternative({
}
}
/**
* The way out of a refusal, where there is one, worded by `t`; null where there is none.
*
* A crossing by ferry is a booking, and the drive follows a booked ferry from terminal to
* terminal. A way offered because it leaves a class of road out is driven once the trip
* avoids that class: without it the router holds such a way only as far as a few pins
* reach, and on a long leg takes the class back between them, so the choice was refused
* after seconds of checking with no word on what would work.
*/
export function refusalHint(
offer: Pick<OfferedRoute, 'hasFerry' | 'avoids'>,
last: Pick<RailLegRoute, 'hasFerry'>,
t: (key: string, params?: Record<string, string | number>) => string,
): string | null {
if (offer.hasFerry && !last.hasFerry) return t('roadtrip.alt.ferryNotHeld')
if (!offer.avoids) return null
return t('roadtrip.alt.avoidNotHeld', { class: t(`roadtrip.avoid.${offer.avoids}`), setting: t('roadtrip.avoid.section') })
}
/**
* The leg the rail drives from the stop filed at `anchor` now, or null when no stop sits
* there any more.
@@ -207,14 +207,16 @@ describe('the drive into the next day (#2461)', () => {
expect(single.remove).toEqual([1])
})
it('FE-REANCHOR-026: a last stop dragged up the day takes its drive along; a stop dragged behind it splits that drive', () => {
it('FE-REANCHOR-026: a last stop dragged up the day takes its drive along, and so does a stop dragged behind it', () => {
// Left alone, the via sat at an index past the end of the day.
const up = reanchorAfterReorder([via(1, 0), via(9, 3)], 3, 1, 4)
expect(up.remove).toEqual([9])
expect(anchors(up.vias)).toEqual({})
// Behind the old last stop now comes the moved one, and the point stays on the road
// leaving the old last stop, the way an insert into any leg keeps its first half.
expect(anchors(reanchorAfterReorder([via(9, 3)], 1, 3, 4).vias)).toEqual({ 9: 2 })
// Behind the old last stop now comes the moved one. The point lay on the road into the
// next day, not on the way to the stop that took its place, so it goes as well.
const behind = reanchorAfterReorder([via(9, 3)], 1, 3, 4)
expect(behind.remove).toEqual([9])
expect(behind.vias).toEqual([])
// Anything moved in front of the last stop leaves it where it was.
expect(isEmptyReanchoring(reanchorAfterReorder([via(9, 3)], 0, 2, 4))).toBe(true)
})
@@ -230,4 +232,16 @@ describe('the drive into the next day (#2461)', () => {
expect(plan.vias, `${from}->${to}`).toEqual([])
}
})
it('FE-REANCHOR-029: under Days and in the server’s sort, a last stop moved up the day takes its drive along too', () => {
// It followed its stop instead, and the drive from 40 to 20 inside the day went
// through a point on the road to tomorrow, for everybody on the trip.
const up = reanchorByStopOrder([via(1, 0), via(9, 3)], [10, 20, 30, 40], [10, 40, 20, 30])
expect(up.remove).toEqual([9])
expect(anchors(up.vias)).toEqual({})
const front = reanchorByStopOrder([via(9, 2)], [10, 20, 30], [30, 10, 20])
expect(front).toEqual({ vias: [], remove: [9] })
// Two stops swapped: the drive out of the one that is first now goes here as well.
expect(reanchorByStopOrder([via(9, 1)], [10, 20], [20, 10])).toEqual({ vias: [], remove: [9] })
})
})
@@ -801,6 +801,41 @@ describe('useRoadtripRoutes', () => {
clock.mockRestore()
}
})
it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => {
// Filed like any answer, it stood in for the weighed road until the page was loaded
// again, and a picker on it read the stand-in line as the road already taken.
let now = 10_000
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now)
try {
setting({ roadtrip_connect_days: true, roadtrip_avoid: 'toll' })
const standIn = { ...routed(1), avoidance: { asked: ['toll'], achieved: [], fellBack: true } }
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
Promise.resolve(wp[0].lat === LUENEBURG[0] ? standIn : routed(1)))
const { days: daysList, assignments } = twoDays()
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.lines).toHaveLength(3))
await waitFor(() => expect(result.current.loading).toBe(false))
const lueneburg = result.current.days[0].stops[1]
const berlin = result.current.days[1].stops[0]
expect(result.current.legRouter!(lueneburg, berlin, 2).standIn).toBe(true)
// The day runs came from the engine asked.
expect(result.current.legRouter!(result.current.days[0].stops[0], lueneburg, 1).standIn).toBe(false)
// Flagged on the card the join arrives on, the way a day's own run is.
expect(result.current.days[1].avoidMissed).toEqual(['toll'])
expect(result.current.days[0].avoidMissed ?? []).toEqual([])
const asked = askedFor(LUENEBURG, BERLIN).length
now += 10_000
calculateRouteWithLegs.mockImplementation(() => Promise.resolve(routed(1)))
act(() => { result.current.reroute!() })
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN).length).toBeGreaterThan(asked))
await waitFor(() => expect(result.current.legRouter!(lueneburg, berlin, 2).standIn).toBe(false))
expect(result.current.days[1].avoidMissed ?? []).toEqual([])
} finally {
clock.mockRestore()
}
})
})
})
@@ -1109,4 +1144,22 @@ describe('a leg asked for again on demand', () => {
expect(result.current.days[0].avoidMissed).toEqual(['ferry', 'toll'])
})
it('FE-ROADTRIP-ROUTES-050: a leg the stand-in drew says so to whoever asks, and rerouting asks the engine again', async () => {
// The rail keeps a day run until its stops or vias change, so a choice of the leg's
// own road, which writes nothing, left the stand-in line on the map.
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll' } })) })
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), avoidance: { asked: ['toll'], achieved: [], fellBack: true } })
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }])))
await waitFor(() => expect(result.current.loading).toBe(false))
const [from, to] = result.current.days[0].stops
expect(result.current.legRouter!(from, to, 1)).toMatchObject({ engine: 'valhalla', standIn: true })
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), avoidance: { asked: ['toll'], achieved: ['toll'], fellBack: false } })
act(() => { result.current.reroute!() })
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
await waitFor(() => expect(result.current.legRouter!(from, to, 1).standIn).toBe(false))
expect(result.current.days[0].avoidMissed ?? []).toEqual([])
})
})
@@ -11,6 +11,7 @@ import type { Assignment, AssignmentsMap, Accommodation, Day, Reservation, Route
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
import { dayWindow } from './dayWindow'
import { useTranslation } from '../../i18n/TranslationContext'
import { stayStartingOn } from '../../utils/dayMerge'
export type { RoadtripStop, RoadtripDay, RoadtripRoutes, QuietDay, AccessSpur, RoutedLeg, SnappedPoint } from '@trek/shared/roadtrip'
@@ -73,11 +74,19 @@ const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>,
.join('|'),
].join('#')
/**
* A drive between two days as it was answered: the leg, the shape it was asked for
* (`seamShape`), whether OSRM answered in place of the engine asked (`standIn`), and the
* classes the answer could not avoid.
*/
type SeamLeg = RoutedLeg & { shape: string; standIn?: boolean; missed?: RouteAvoidClass[] }
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
const EMPTY_RESERVATIONS: Reservation[] = []
const NO_STAND_INS: ReadonlySet<string> = new Set()
/** One leg as the rail's own router answered it, for a surface checking a choice against it. */
export interface RailLegRoute {
@@ -107,6 +116,12 @@ export interface RailLegRouter {
avoid: RouteAvoidClass[]
/** Which engine prices this leg on the rail. */
engine: RouteEngine
/**
* True when the line the rail has for this leg came from OSRM standing in for `engine`,
* which did not answer when the leg was routed. The leg's own road is then not what the
* map shows, whatever its vias say, and only asking again (`reroute`) puts it there.
*/
standIn: boolean
/** The leg from its first stop through `pins` to its last, asked exactly as the rail asks it. */
route: (pins: { lat: number; lng: number }[], signal: AbortSignal) => Promise<RailLegRoute>
}
@@ -124,6 +139,15 @@ export type RoadtripRoutesView = RoadtripRoutes & {
* different days are a seam, asked for under the card it arrives on.
*/
legRouter?: (from: RoadtripStop, to: RoadtripStop, cardDayId: number) => RailLegRouter
/**
* Asks the router again for every leg OSRM drew while the engine above it did not
* answer: the days whose run fell back, and the joins between days that did.
*
* Nothing else would. A day run is asked again only when its stops or vias change, and
* a choice of the leg's own road writes nothing, so the stand-in line stayed on the map
* under a picker that said the road had been taken.
*/
reroute?: () => void
}
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
@@ -132,7 +156,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda
// Check-in only. A check-out is the LATEST the room has to be handed back, not the
// earliest anybody may leave, so it says nothing about when the drive sets off and
// has no business in the chain. It stays a booking detail, shown under Days.
const stay = accommodations.find(stay => stay.place_id === a.place_id && stay.start_day_id === ownerDayId)
const stay = stayStartingOn(accommodations, a.place_id, ownerDayId)
return {
assignmentId: a.id,
ownerDayId,
@@ -215,7 +239,11 @@ export function useRoadtripRoutes(
* it draws two disconnected runs on the map. Filed by leg key like every other leg, so
* everything downstream reads them without knowing where they came from.
*/
const [seamLegs, setSeamLegs] = useState<Record<string, RoutedLeg & { shape: string }>>({})
const [seamLegs, setSeamLegs] = useState<Record<string, SeamLeg>>({})
/** The legs of the day runs that OSRM drew while the engine asked did not answer. */
const [standInLegs, setStandInLegs] = useState<ReadonlySet<string>>(NO_STAND_INS)
/** Bumped to route everything again (`reroute`): every answer already cached comes back at once. */
const [rerouteRound, setRerouteRound] = useState(0)
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
@@ -363,6 +391,7 @@ export function useRoadtripRoutes(
setLegsByDay({})
setSnapByDay({})
setMissedByDay({})
setStandInLegs(NO_STAND_INS)
setLoading(false)
return
}
@@ -373,6 +402,7 @@ export function useRoadtripRoutes(
const collected: Record<number, Record<string, RoutedLeg>> = {}
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
const collectedStandIns = new Set<string>()
const tasks: (() => Promise<void>)[] = []
/**
@@ -462,7 +492,11 @@ export function useRoadtripRoutes(
collectedMisses[day.dayId] = [...new Set([...(collectedMisses[day.dayId] ?? []), ...missed])]
}
}
Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode))
const folded = foldRouteRun(run, stopAt, r, mode)
// Remembered per leg, so a picker opened on one of them knows the line on the
// map is not its engine's and does not read it as the road already taken.
if (r.avoidance?.fellBack) for (const key of Object.keys(folded)) collectedStandIns.add(key)
Object.assign(dayLegs, folded)
return
} catch (err) {
if (controller.signal.aborted) return
@@ -505,6 +539,7 @@ export function useRoadtripRoutes(
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
setStandInLegs(new Set(collectedStandIns))
}
void (async () => {
for (let i = 0; i < tasks.length; i++) {
@@ -530,7 +565,7 @@ export function useRoadtripRoutes(
// planKey stands in for `plan`: same geometry, same legs. avoidKey, not `avoid`:
// a fresh array every render would re-route on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [planKey, routeProfile, distanceUnit, tripId, avoidKey])
}, [planKey, routeProfile, distanceUnit, tripId, avoidKey, rerouteRound])
/**
* Every leg known so far, by the two stops it connects.
@@ -566,8 +601,9 @@ export function useRoadtripRoutes(
* run. Left unrouted the rail shows a chain that stops halfway and the map draws the
* day in two pieces with a gap across the middle.
*/
const seams = useMemo(() => {
const { seams, seamMisses } = useMemo(() => {
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number; mode: string; shape: string }[] = []
const misses: Record<number, RouteAvoidClass[]> = {}
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
// The two ends of one ride: the leg between them is the booking's, never a road.
if (rideLegs[legKey(from, to)]) return
@@ -581,7 +617,14 @@ export function useRoadtripRoutes(
const mode = legModeOf(from, to, dayId)
const shape = seamShape(from, viasByDay, mode, avoidKey)
const have = seamLegs[legKey(from, to)]
if (have && have.shape === shape) return
const current = have?.shape === shape
// What the drawn join could not avoid is flagged on the card it arrives on, the way
// a day's own run is. A join OSRM drove in the engine's place said nothing at all.
if (current && have.missed?.length) misses[dayId] = [...new Set([...(misses[dayId] ?? []), ...have.missed])]
// An answer OSRM gave in the engine's place is drawn but not settled: it is asked
// for again on the next seam round, instead of standing in for the weighed road
// until the page is loaded again.
if (current && !have.standIn) return
out.push({ from, to, dayId, mode, shape })
}
const routingChains = window
@@ -605,8 +648,16 @@ export function useRoadtripRoutes(
if (from && to) want(from, to, routingChains[d + 1].dayId)
}
}
return out
return { seams: out, seamMisses: misses }
}, [chains, plan, quietDays, window, legsByDay, seamLegs, rideLegs, viasByDay, connectDays, legModeOf, avoidKey])
/** Each card's unavoided classes, its own run's and those of the join it is reached by. */
const missedOnCards = useMemo(() => {
const out: Record<number, RouteAvoidClass[]> = { ...missedByDay }
for (const [dayId, classes] of Object.entries(seamMisses)) {
out[Number(dayId)] = [...new Set([...(out[Number(dayId)] ?? []), ...classes])]
}
return out
}, [missedByDay, seamMisses])
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${s.shape}`).join(';')
/**
* When the last request outside the routing round went out, across every run of the
@@ -682,6 +733,8 @@ export function useRoadtripRoutes(
line: r.coordinates,
vias: [],
shape,
standIn: r.avoidance?.fellBack === true,
missed: r.avoidance ? r.avoidance.asked.filter(cls => !r.avoidance!.achieved.includes(cls)) : [],
},
}))
} catch {
@@ -696,7 +749,7 @@ export function useRoadtripRoutes(
// seamKey stands in for `seams`: same pairs, same requests. avoidKey, not `avoid`:
// a fresh array every render would re-ask for every seam on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seamKey, routeProfile, tripId, avoidKey])
}, [seamKey, routeProfile, tripId, avoidKey, rerouteRound])
const legRouter = useCallback((from: RoadtripStop, to: RoadtripStop, cardDayId: number): RailLegRouter => {
// The day the routing round asked this pair under: its own stored day for a pair
@@ -704,10 +757,14 @@ export function useRoadtripRoutes(
// is what a route provider plugin is told.
const dayId = from.ownerDayId === to.ownerDayId ? from.ownerDayId : cardDayId
const mode = legModeOf(from, to, dayId)
// Whose line the rail shows, read the way `allLegs` picks it: a day run's over a seam's.
const key = legKey(from, to)
const byRun = plan.some(day => legsByDay[day.dayId]?.[key] !== undefined)
return {
mode,
avoid: avoidedClasses(mode, avoid),
engine: routeEngineFor(mode, avoid),
standIn: byRun ? standInLegs.has(key) : seamLegs[key]?.standIn === true,
route: async (pins, signal) => {
await spacedRequest(signal)
const r = await calculateRouteWithLegs(
@@ -723,10 +780,12 @@ export function useRoadtripRoutes(
}
},
}
}, [legModeOf, avoid, spacedRequest, tripId])
}, [legModeOf, avoid, spacedRequest, tripId, plan, legsByDay, standInLegs, seamLegs])
const assembled = useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries,
const reroute = useCallback(() => setRerouteRound(round => round + 1), [])
const assembled = useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay: missedOnCards, loading, limits, vehicleKind, connectDays, boundaries,
labels: { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') },
}), [plan, quietDays, window, distanceUnit, t, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries])
return useMemo(() => ({ ...assembled, legRouter }), [assembled, legRouter])
}), [plan, quietDays, window, distanceUnit, t, allLegs, snapByDay, missedOnCards, loading, limits, vehicleKind, connectDays, boundaries])
return useMemo(() => ({ ...assembled, legRouter, reroute }), [assembled, legRouter, reroute])
}
@@ -13,7 +13,7 @@ import { ARRIVING_DRIVE, openOn, sameDrive, useRouteAlternatives, type Alternati
import type { RailLegRouter } from './useRoadtripRoutes'
/**
* FE-ALTHOOK-001..013: asking for other ways of driving one leg.
* FE-ALTHOOK-001..015: asking for other ways of driving one leg.
*
* The case that carries the feature: the road the rail drives is always the first entry,
* taken from the rail, and the offers come from the engine the rail drives the leg with.
@@ -46,7 +46,7 @@ function request(router: Partial<RailLegRouter> = {}, over: Partial<Alternatives
driven: { coordinates: RAIL, distance: 290_000, duration: 10_800 },
anchor: { dayId: 3, afterIndex: 2 },
ends: { from: 11, to: 12 },
router: { mode: 'driving', avoid: [], engine: 'osrm', route, ...router },
router: { mode: 'driving', avoid: [], engine: 'osrm', standIn: false, route, ...router },
...over,
}
}
@@ -254,6 +254,40 @@ describe('useRouteAlternatives', () => {
})
})
describe('a leg OSRM drew while the rail’s engine did not answer', () => {
it('FE-ALTHOOK-014: Current is marked as OSRM’s line, and the picker knows the leg was drawn by the stand-in', async () => {
// Marked as the rail's engine, the stand-in line was set against the second engine's
// offers as if one speed model had timed them all.
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(request({ avoid: ['toll'], engine: 'valhalla', standIn: true })) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open).toMatchObject({ engine: 'valhalla', standIn: true })
expect(result.current.open?.routes[0]).toMatchObject({ current: true, engine: 'osrm' })
})
it('FE-ALTHOOK-015: a check that saved nothing leaves its reason on the picker until the next one starts', async () => {
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(request()) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.notice).toBeNull()
act(() => { result.current.prove(1) })
act(() => { result.current.settle('Not saved.') })
expect(result.current.open).toMatchObject({ proving: null, notice: 'Not saved.' })
act(() => { result.current.prove(2) })
expect(result.current.open).toMatchObject({ proving: 2, notice: null })
act(() => { result.current.settle() })
expect(result.current.open?.notice).toBeNull()
act(() => { result.current.settle('Not saved.') })
act(() => { result.current.ask(request({}, { drive: { kind: 'leg', index: 0 } })) })
expect(result.current.open?.notice).toBeNull()
})
})
describe('which drive a picker is open on', () => {
it('FE-ALTHOOK-013: the drive in from the day before is its own drive, never a leg at some index', () => {
expect(sameDrive(ARRIVING_DRIVE, { kind: 'arriving' })).toBe(true)
@@ -58,6 +58,11 @@ export interface LegAlternatives {
ends: { from: number; to: number }
/** The engine the rail drives this leg with. Offers from another cannot be read against it. */
engine: RouteEngine
/**
* True when the line the rail has for this leg came from OSRM standing in for `engine`,
* which did not answer. Its own road is then not on the map, whatever the leg's vias say.
*/
standIn: boolean
/** Routes the leg through pins exactly as the rail does, to check a choice before saving it. */
route: RailLegRouter['route']
routes: OfferedRoute[]
@@ -65,6 +70,8 @@ export interface LegAlternatives {
error: boolean
/** The offer being checked against the rail's router and saved, or null while none is. */
proving: number | null
/** Why the last check saved nothing, for the bar to say beside the offers. Null otherwise. */
notice: string | null
}
/** Everything a picker needs to know about the leg it is opened on. */
@@ -91,8 +98,11 @@ export interface RouteAlternativesState {
* closes, moves to another leg, or another check starts.
*/
prove: (index: number) => AbortSignal
/** Ends a check that led to no save, leaving the picker open for another choice. */
settle: () => void
/**
* Ends a check that led to no save, leaving the picker open for another choice. `notice`
* says why, for the bar to announce.
*/
settle: (notice?: string) => void
}
/**
@@ -134,15 +144,18 @@ export function useRouteAlternatives(): RouteAlternativesState {
const controller = new AbortController()
abortRef.current = controller
const { dayId, drive, from, to, driven, anchor, ends, router } = request
const leg = { dayId, drive, anchor, ends, engine: router.engine, route: router.route, proving: null }
const leg = { dayId, drive, anchor, ends, engine: router.engine, standIn: router.standIn, route: router.route, proving: null, notice: null }
setOpen({ ...leg, routes: [], loading: true, error: false })
// Marked with the engine that drew it. A line OSRM drew while the rail's engine did not
// answer was marked as that engine's, so the list set it against the offers as if one
// speed model had timed them all.
const current: OfferedRoute = {
coordinates: driven.coordinates,
distance: driven.distance,
duration: driven.duration,
divergence: null,
engine: router.engine,
engine: router.standIn ? 'osrm' : router.engine,
current: true,
}
// A plugin's mode means nothing to the routers asked here, so its leg is offered the
@@ -176,13 +189,13 @@ export function useRouteAlternatives(): RouteAlternativesState {
stopProving()
const controller = new AbortController()
provingRef.current = controller
setOpen(o => (o ? { ...o, proving: index } : o))
setOpen(o => (o ? { ...o, proving: index, notice: null } : o))
return controller.signal
}, [stopProving])
const settle = useCallback(() => {
const settle = useCallback((notice?: string) => {
provingRef.current = null
setOpen(o => (o ? { ...o, proving: null } : o))
setOpen(o => (o ? { ...o, proving: null, notice: notice ?? null } : o))
}, [])
// One object for as long as nothing in it changes. The planner keys its close gate and
@@ -1,7 +1,7 @@
import { AlertTriangle, Check, Loader2, Shuffle, X } from 'lucide-react'
import MBadge from '../../../components/MBadge'
import MIconBtn from '../../../components/MIconBtn'
import { alternativeSubline, type AlternativeOverlay } from '../../../../components/Roadtrip/alternativeOverlays'
import { alternativeSubline, otherEngineNote, type AlternativeOverlay } from '../../../../components/Roadtrip/alternativeOverlays'
import { useSettingsStore } from '../../../../store/settingsStore'
import { formatDistance } from '../../../../utils/units'
import { badgeLabel } from './stageBadges'
@@ -31,6 +31,7 @@ export interface MRtAlternativesBarProps {
export default function MRtAlternativesBar({ planner, alts }: MRtAlternativesBarProps) {
const { t } = planner
const title = t('roadtrip.alt.title')
const note = alts.picked ? otherEngineNote(alts.picked) : null
return (
<section
@@ -55,9 +56,9 @@ export default function MRtAlternativesBar({ planner, alts }: MRtAlternativesBar
<div className="flex h-11 flex-none items-center gap-2.5">
{/* The desk hides this sentence in a tooltip on the chip. There is no hover here,
so it stands beside the button whose choice it qualifies. */}
{alts.picked?.otherEngine && (
{note && (
<span className="line-clamp-2 min-w-0 flex-1 font-geist text-[0.65625rem] leading-[1.35] text-m-muted">
{t('roadtrip.alt.otherEngine')}
{t(note)}
</span>
)}
<button
@@ -68,7 +69,7 @@ export default function MRtAlternativesBar({ planner, alts }: MRtAlternativesBar
// Saving keeps the fill: the button is busy with the choice, not unavailable.
className={`flex h-11 min-w-0 items-center justify-center gap-[7px] rounded-full bg-m-act px-5 text-[0.8125rem] font-semibold text-m-actfg ${
alts.saving ? '' : 'disabled:bg-[color:var(--m-ic)] disabled:text-m-faint'
} ${alts.picked?.otherEngine ? 'flex-none' : 'flex-1'}`}
} ${note ? 'flex-none' : 'flex-1'}`}
>
{alts.saving
? <Loader2 size={15} strokeWidth={2.2} className="flex-none animate-spin" aria-hidden="true" />
@@ -96,6 +96,7 @@ const rt = vi.hoisted(() => {
const legRoute = vi.fn()
const routes = {
legRouter: vi.fn(),
reroute: vi.fn(),
days: [] as Array<Record<string, unknown>>,
quietDays: [] as unknown[],
lines: [] as unknown[],
@@ -1119,7 +1120,8 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
expect(rt.vias.addMany).not.toHaveBeenCalled()
expect(rt.alt.close).not.toHaveBeenCalled()
expect(rt.alt.settle).toHaveBeenCalled()
// Said to the bar as well, which keeps it beside the offers and announces it.
expect(rt.alt.settle).toHaveBeenCalledWith('The road trip’s router won’t follow this way, so it was not saved.')
expect(toasts).toContainEqual({ message: 'The road trip’s router won’t follow this way, so it was not saved.', type: 'error' })
// Nothing about a ferry the offer never took.
expect(toasts).not.toContainEqual(expect.objectContaining({ message: expect.stringContaining('ferry') }))
@@ -1134,13 +1136,75 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
await act(async () => { await result.current.chooseRouteAlternative(1) })
expect(rt.vias.addMany).not.toHaveBeenCalled()
const ferryHint = 'This way crosses by ferry. Add the ferry as a transport booking and the drive follows it. If it lands the next day, put the stops across the water on that day.'
expect(toasts).toContainEqual({ message: ferryHint, type: 'info' })
// The ferry is the way out here, not the motorway the offer also leaves out.
expect(toasts).not.toContainEqual(expect.objectContaining({ message: expect.stringContaining('ticked') }))
expect(rt.alt.settle).toHaveBeenCalledWith(`The road trip’s router won’t follow this way, so it was not saved. ${ferryHint}`)
})
it('FE-TP-ROAD-135: a way that leaves a class out says the trip has to avoid it to be driven', async () => {
// The router holds such a way only as far as a few pins reach, so on a long leg the
// choice was refused after the wait with no word on what would get it driven.
routedDay()
openWith([detour({ avoids: 'motorway', engine: 'valhalla' })])
rt.legRoute.mockResolvedValue(answer(RAIL_LINE, { hasFerry: false }))
const { result } = await renderRoadtrip()
await act(async () => { await result.current.chooseRouteAlternative(1) })
expect(rt.vias.addMany).not.toHaveBeenCalled()
expect(toasts).toContainEqual({
message: 'This way crosses by ferry. Add the ferry as a transport booking and the drive follows it.',
message: 'The road trip only drives this way with “Motorways” ticked under “Avoid where possible” in its settings.',
type: 'info',
})
})
it('FE-TP-ROAD-136: the router own road on a leg OSRM drew in its place asks the rail for the leg again, and says so', async () => {
// Nothing bends the leg, so nothing is written, and the stand-in line stayed on the map
// under a picker that closed as if the road had been taken.
routedDay()
openWith([detour({ direct: true, engine: 'valhalla' })], { engine: 'valhalla', standIn: true })
rt.legRoute.mockResolvedValue(answer(DETOUR))
const { result } = await renderRoadtrip()
await act(async () => { await result.current.chooseRouteAlternative(1) })
expect(rt.vias.addMany).not.toHaveBeenCalled()
expect(rt.routes.reroute).toHaveBeenCalledTimes(1)
expect(toasts).toContainEqual({
message: 'The avoidance router did not answer for this leg, so the main router drew it. It is being asked again.',
type: 'info',
})
expect(rt.alt.close).toHaveBeenCalled()
})
it('FE-TP-ROAD-137: pins written for a leg OSRM drew route it again by themselves, and it still says why the line may wait', async () => {
routedDay()
openWith([detour({ engine: 'valhalla' })], { engine: 'valhalla', standIn: true })
rt.legRoute.mockResolvedValue(answer(DETOUR))
const { result } = await renderRoadtrip()
await act(async () => { await result.current.chooseRouteAlternative(1) })
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [{ after_order_index: 0, lat: 53.3, lng: 12.8 }], [0])
expect(rt.routes.reroute).not.toHaveBeenCalled()
expect(toasts).toContainEqual(expect.objectContaining({ message: expect.stringContaining('did not answer for this leg'), type: 'info' }))
})
it('FE-TP-ROAD-138: on a leg its own engine drew, the router own road changes nothing and asks nothing again', async () => {
routedDay()
openWith([detour({ direct: true })], { standIn: false })
rt.legRoute.mockResolvedValue(answer(DETOUR))
const { result } = await renderRoadtrip()
await act(async () => { await result.current.chooseRouteAlternative(1) })
expect(rt.routes.reroute).not.toHaveBeenCalled()
expect(toasts).not.toContainEqual(expect.objectContaining({ message: expect.stringContaining('did not answer') }))
})
it('FE-TP-ROAD-119: the router own road on a leg nothing bends writes nothing and closes', async () => {
routedDay()
openWith([detour({ direct: true })])
+24 -11
View File
@@ -43,7 +43,7 @@ import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
import { openOn, useRouteAlternatives, type RailDrive } from '../../components/Roadtrip/useRouteAlternatives'
import { alternativesBusy, buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
import { pinAlternative, railDriveOn, railLegAt, type PinProof } from '../../components/Roadtrip/alternativePins'
import { pinAlternative, railDriveOn, railLegAt, refusalHint, type PinProof } from '../../components/Roadtrip/alternativePins'
import { stopArrival } from '../../components/Roadtrip/stopArrival'
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
import { projectOntoRoute, sliceAtMeters, type LatLng } from '../../components/Roadtrip/corridor'
@@ -1646,6 +1646,12 @@ export function useTripPlanner() {
* between each of them. A write that fails is reported and leaves the picker open:
* swallowed, it closed on a leg that still carried its via, and not even the reload ran
* to contradict the traveller.
*
* A refusal is said twice: as a toast, and to the bar (`settle`), which keeps it beside
* the offers and announces it. A leg the rail drew with OSRM standing in for an engine
* that did not answer is asked for again once a choice holds, since the road the router
* chose is not the one on the map: taking the router's own road there wrote nothing and
* left the stand-in line in place, which read as a click that did nothing.
*/
const chooseRouteAlternative = useCallback(async (index: number) => {
const open = routeAlternatives.open
@@ -1664,17 +1670,17 @@ export function useTripPlanner() {
proof = await pinAlternative({ offer, current: open.routes.find(r => r.current), route: open.route, signal })
} catch {
if (signal.aborted) return
routeAlternatives.settle()
routeAlternatives.settle(t('roadtrip.alt.failed'))
toast.error(t('roadtrip.alt.failed'))
return
}
if (signal.aborted) return
if (!proof.held) {
routeAlternatives.settle()
toast.error(t(proof.fellBack ? 'roadtrip.alt.failed' : 'roadtrip.alt.notHeld'), 6000)
// The one refusal with a way out: a crossing by ferry is a booking, and the drive
// follows a booked ferry from terminal to terminal.
if (!proof.fellBack && offer.hasFerry && !proof.last.hasFerry) toast.info(t('roadtrip.alt.ferryNotHeld'), 8000)
const refusal = t(proof.fellBack ? 'roadtrip.alt.failed' : 'roadtrip.alt.notHeld')
const hint = proof.fellBack ? null : refusalHint(offer, proof.last, t)
routeAlternatives.settle(hint ? `${refusal} ${hint}` : refusal)
toast.error(refusal, 6000)
if (hint) toast.info(hint, 8000)
return
}
@@ -1687,7 +1693,10 @@ export function useTripPlanner() {
routeAlternatives.close()
return
}
// The router's own road on a leg nothing bends is already what is driven.
// The router's own road on a leg nothing bends is already what is driven, unless OSRM
// drew the leg in its engine's place. A write routes the leg again by itself; without
// one the rail is asked to, and either way the picker says why the map may still hold
// the stand-in line for a moment, or for as long as that engine does not answer.
const bent = viasLeaving(leg.from, roadtripVias.byDay[anchor.dayId] ?? []).length > 0
if (proof.pins.length || bent) {
try {
@@ -1698,14 +1707,18 @@ export function useTripPlanner() {
)
} catch (err: unknown) {
// Said even when the picker has moved on in the meantime: the write was asked for.
if (!signal.aborted) routeAlternatives.settle()
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
const message = err instanceof Error ? err.message : t('common.unknownError')
if (!signal.aborted) routeAlternatives.settle(message)
toast.error(message)
return
}
} else if (open.standIn) {
roadtripRoutes.reroute?.()
}
if (open.standIn) toast.info(t('roadtrip.alt.standIn'), 8000)
// A picker opened on another leg while this was written belongs to that leg now.
if (!signal.aborted) routeAlternatives.close()
}, [routeAlternatives, roadtripVias, toast, t])
}, [routeAlternatives, roadtripVias, roadtripRoutes, toast, t])
/**
* A click on the drawn route puts a via there, and the drive is redrawn through it.
+57 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems, rideSeatKey, timedSlot } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems, rideSeatKey, stayStartingOn, storedRideSlot, timedSlot } from './dayMerge'
describe('parseTimeToMinutes', () => {
it('parses HH:MM string', () => {
@@ -336,7 +336,9 @@ describe('getMergedItems: a ride within one day, seated by where it goes (#2461)
})
it('opens the day with a ride that lands next to its first stop, ahead of a note too', () => {
expect(read([newcastle()], [ferry()], [{ id: 10, sort_order: 0 }])).toEqual(['transport:69', 'place:2', 'note:10'])
// The note keeps its place ahead of the stop; the ride goes ahead of both.
expect(read([newcastle()], [ferry()], [{ id: 10, sort_order: 0 }])).toEqual(['transport:69', 'note:10', 'place:2'])
expect(read([newcastle()], [ferry()], [{ id: 10, sort_order: 2 }])).toEqual(['transport:69', 'place:2', 'note:10'])
})
it('leaves a slot somebody stored where it is', () => {
@@ -357,6 +359,59 @@ describe('getMergedItems: a ride within one day, seated by where it goes (#2461)
})
})
// The slot the desktop stores for a ride nobody placed yet, worked out over the rows the
// road trip drives rather than the ones the list shows (#2461).
describe('storedRideSlot', () => {
const row = (id: number, order_index: number, lat: number | null, lng: number | null, place_time: string | null = null) =>
({ id, order_index, place_id: id * 10, place: { lat, lng, place_time } })
const amsterdam = row(1, 0, 52.3731, 4.8926)
// The hotel a booking put on the day: hidden in the list, a stop of the drive.
const hotel = row(3, 1, 54.975, -1.61)
const ferry = (over: Record<string, unknown> = {}) => ({
id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 5, end_day_id: 5,
reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'IJmuiden', lat: 52.4581, lng: 4.5879 },
{ role: 'to', sequence: 1, name: 'Port of Tyne', lat: 54.9925, lng: -1.4522 },
],
...over,
})
it('puts a ferry ahead of the hotel across the water that the list does not show', () => {
// Over the visible Amsterdam alone the clock closed the day at 1.5, behind the hotel,
// and the drive went there overland before the crossing.
expect(storedRideSlot(ferry(), [amsterdam, hotel], [{ place_id: 30, start_day_id: 5, check_in: null }], 5)).toBe(0.5)
})
it('times a night by the check-in of the stay that starts there, and the clock still binds', () => {
// Checked in at three, the hotel is behind the drive by the 17:30 sailing already.
const checkIn = [{ place_id: 30, start_day_id: 5, check_in: '15:00' }]
expect(storedRideSlot(ferry(), [amsterdam, hotel], checkIn, 5)).toBe(1.5)
// A stay that starts on another day says nothing about this one.
expect(storedRideSlot(ferry(), [amsterdam, hotel], [{ ...checkIn[0], start_day_id: 4 }], 5)).toBe(0.5)
})
it('opens the day, closes it, or sits behind a timed row, measured on order indexes', () => {
expect(storedRideSlot(ferry(), [row(2, 3, 54.9783, -1.6178)], [], 5)).toBe(2.5)
expect(storedRideSlot(ferry(), [amsterdam], [], 5)).toBe(0.5)
expect(storedRideSlot(ferry(), [amsterdam, row(2, 1, 54.9783, -1.6178, '10:00')], [], 5)).toBe(1.5)
// A row without coordinates is no stop of the drive, but the day still closes behind it.
expect(storedRideSlot(ferry(), [amsterdam, row(4, 1, null, null)], [], 5)).toBe(1.5)
})
it('leaves anything but a ride within one day to the clock', () => {
expect(storedRideSlot(ferry({ type: 'taxi' }), [amsterdam, hotel], [], 5)).toBeNull()
expect(storedRideSlot(ferry({ end_day_id: 6 }), [amsterdam, hotel], [], 5)).toBeNull()
expect(storedRideSlot(ferry(), [], [], 5)).toBeNull()
})
it('finds the stay by its place and the day it starts', () => {
const stays = [{ id: 1, place_id: 30, start_day_id: 4 }, { id: 2, place_id: 30, start_day_id: 5 }]
expect(stayStartingOn(stays, 30, 5)?.id).toBe(2)
expect(stayStartingOn(stays, 31, 5)).toBeUndefined()
})
})
// A stop with a start is drawn by it, and the road trip drives the stored order, so a
// stop joining a day from elsewhere is stored where the list will draw it.
describe('timedSlot', () => {
+63
View File
@@ -322,6 +322,69 @@ export function rideSeatKey(r: CarrierBooking, rows: readonly MergedItem[]): num
return ride ? rideSeatAfter(rows.flatMap(seatItemsOf), ride) : null
}
/** A booked stay, as far as the road trip times a stop by it. */
interface StayStart {
place_id?: number | null
start_day_id?: number | null
check_in?: string | null
}
/**
* The stay a stop at `placeId` begins on day `dayId`, the one whose check-in the road trip
* times that stop by. Only the day the stay starts: a check-out says when the room has to
* be handed back, not when the drive sets off.
*/
export function stayStartingOn<T extends StayStart>(stays: readonly T[], placeId: number | null | undefined, dayId: number): T | undefined {
return stays.find(stay => stay.place_id === placeId && stay.start_day_id === dayId)
}
/** A stored row of a day, as far as `storedRideSlot` reads it. */
interface StoredStop {
order_index: number
place_id?: number | null
place?: { lat?: number | null; lng?: number | null; place_time?: string | null } | null
}
/**
* The slot to store for a ride that lands on the day it left (`sameDayRide`) and has none
* yet: worked out over the day's rows the way the road trip seats its terminals among
* them (`seatCarrierStops`), so the slot the list is drawn by and the drive agree. Null for
* any other booking, which keeps the clock's slot.
*
* Read over every stored row the drive stops at, the ones the list hides included: the
* hotel a booking put on the day, a petrol station kept out of the list. The slot is a
* number among their order indexes all the same, and worked out over the rows the list
* shows it could land behind a hidden stop on the far shore, which the drive then went to
* overland before the crossing (#2461). Each row counts with its start, else the check-in
* of the stay that begins there that day (`stayStartingOn`), and with its coordinates; a
* row without them is no stop of the drive. Where the rule keeps the clock's seat, the
* clock is read over the same rows: behind the last one timed at or before the departure,
* else at the end of the day.
*/
export function storedRideSlot(
r: CarrierBooking,
day: readonly StoredStop[],
stays: readonly StayStart[],
dayId: number,
): number | null {
const ride = sameDayRide(r)
if (!ride || !day.length) return null
const items: SeatItem[] = day.flatMap(row => {
const lat = row.place?.lat
const lng = row.place?.lng
if (typeof lat !== 'number' || typeof lng !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lng)) return []
const start = row.place?.place_time ?? stayStartingOn(stays, row.place_id, dayId)?.check_in
return [{ key: row.order_index, minutes: parseTimeToMinutes(start), point: { lat, lng } }]
})
const keys = day.map(row => row.order_index)
const seat = rideSeatAfter(items, ride)
if (seat === -Infinity) return Math.min(...keys) - 0.5
if (seat !== null) return seat + 0.5
const departs = ride.minutes ?? 0
const before = items.filter(item => item.minutes !== null && item.minutes <= departs).map(item => item.key)
return (before.length ? Math.max(...before) : Math.max(...keys)) + 0.5
}
/** Merge places, notes, and transports into a single ordered day timeline. */
export function getMergedItems(opts: {
dayAssignments: any[]
+2
View File
@@ -18,7 +18,9 @@ export function openLeg(over: Partial<LegAlternatives> = {}): LegAlternatives {
anchor: { dayId: over.dayId ?? 2, afterIndex: over.drive?.kind === 'leg' ? over.drive.index : 0 },
ends: { from: 1, to: 2 },
engine: 'osrm',
standIn: false,
route: vi.fn(),
notice: null,
...over,
}
}
@@ -8,7 +8,7 @@ import type { LegAlternatives, OfferedRoute } from '../../../../src/components/R
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openLeg } from '../../../helpers/legAlternatives'
// FE-MOB-RTALT-001 to FE-MOB-RTALT-012
// FE-MOB-RTALT-001 to FE-MOB-RTALT-013
//
// Rendered through the real controller hook, so a tap is checked all the way to what the
// planner is asked to do, and the pending state is the transition's own.
@@ -181,6 +181,23 @@ describe('MRtAlternativesBar', () => {
expect(confirmButton().className).toContain('flex-1')
})
it('FE-MOB-RTALT-013: on a leg a route provider drives, a road OSRM timed is said to be OSRM’s', () => {
// It used to be called the avoidance router's, an engine that timed none of these.
const routes: OfferedRoute[] = [{ ...ROUTES[0], engine: 'plugin' }, ROUTES[1]]
const base = buildPlanner()
renderBar(buildPlanner({
selectedDayId: 2,
routeAlternatives: { ...base.routeAlternatives, open: openLeg({ dayId: 2, drive: { kind: 'leg', index: 0 }, routes, engine: 'plugin' }) },
alternativeOverlays: buildAlternativeOverlays(routes, {
fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry',
}, 'plugin'),
highlightedAlternative: 1,
} as Partial<TripPlanner>))
expect(screen.getByText('roadtrip.alt.otherEngineStandard')).toBeInTheDocument()
expect(screen.queryByText('roadtrip.alt.otherEngine')).toBeNull()
})
it('FE-MOB-RTALT-010: offline a valid pick still cannot be confirmed', () => {
const base = buildPlanner()
renderBar(planner({}, { highlightedAlternative: 1, roadtripVias: { ...base.roadtripVias, editable: false } }))
+7 -8
View File
@@ -1,5 +1,5 @@
import type Database from 'better-sqlite3';
import { carriedSeam } from '@trek/shared/roadtrip';
import { seamViaIndex } from '@trek/shared/roadtrip';
/**
* Where a booked night sits in its day, and how the drawn roads follow when a stop
@@ -176,9 +176,10 @@ export function reseatOwnStop(
* The rules the planner applies when a stop is dragged or taken out: a via follows
* its stop, a stop that left the day hands its road to the stop before it, and a
* stop that is last has no leg to keep a via on. A via behind the day's last stop
* bends the drive into the next day and stays with that stop while it is still last,
* whatever its number is now; measured as a leg it would be dropped. That last rule is
* the planner's own (`carriedSeam`), so the two cannot disagree about it.
* bends the drive into the next day: it stays with that stop while it is still last,
* whatever its number is now, and goes once another stop is last, because it lies on
* the road to tomorrow and no leg of the day. That rule is the planner's own
* (`seamViaIndex`), so the two cannot disagree about it.
*
* Returns what changed, or null when nothing did.
*/
@@ -187,13 +188,11 @@ export function carryVias(db: SeatConnection, dayId: number, previousIds: number
const vias = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?').all(dayId) as PinnedVia[];
if (!vias.length) return null;
const seam = carriedSeam(previousIds, nextIds);
const remove: number[] = [];
const moved: { id: number; after_order_index: number }[] = [];
for (const via of vias) {
const next = seam && via.after_order_index === seam.from
? seam.to
: legAfter(via.after_order_index, previousIds, nextIds);
const seam = seamViaIndex(via.after_order_index, previousIds, nextIds);
const next = seam !== undefined ? seam : legAfter(via.after_order_index, previousIds, nextIds);
if (next === null) remove.push(via.id);
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
}
@@ -342,8 +342,10 @@ export class AssignmentsService {
// A via behind the day's last stop bends the drive into the next day, on a trip
// with connected days or a night drive (the planner's `anchorFor` files it there).
// It stays with that stop while the sort leaves it last (`carriedSeam`, applied
// inside `reanchorByStopOrder`), the same rule the planner's own drags follow.
// It stays with that stop while the sort leaves it last, and goes once the sort puts
// another stop last (`seamViaIndex`, applied inside `reanchorByStopOrder`): it lies on
// the road to tomorrow, not on the leg that stop leaves by now. The same rule the
// planner's own drags follow.
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId);
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
for (const viaId of plan.remove) {
@@ -49,7 +49,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'calculate_roadtrip',
description:
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking on no day is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date with the reservation tools, after which the road ends at its terminal. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
inputSchema: roadtripPlanRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -128,14 +128,14 @@ describe('booked night reseat', () => {
reseatBookedNights(db);
// The rules the service applies when it moves a night: a via follows the stop
// it was drawn after, so A's road is on leg one now and the hotel's on leg
// zero; B is last now and has no leg to keep a via on, so its road goes.
// it was drawn after, so A's road is on leg one now; B is last now and has no leg
// to keep a via on, so its road goes. The hotel's road was the drive into the next
// day, behind the last stop, and goes too now that the hotel leads the day: on the
// leg out of the hotel it would bend the drive to A through a point on that road.
expect(order(db)).toEqual([hotel, a, b]);
expect(vias(db)).toEqual([
{ id: afterHotel, after_order_index: 0, sequence: 0 },
{ id: afterA, after_order_index: 1, sequence: 0 },
]);
expect(vias(db)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
expect(vias(db).map((v) => v.id)).not.toContain(afterB);
expect(vias(db).map((v) => v.id)).not.toContain(afterHotel);
});
it('RESEAT-006: a road behind the last stop stays with it, and the roads ahead follow their stops', () => {
@@ -1284,16 +1284,17 @@ describe('the drawn roads a booking moves', () => {
const existing = svc.getAccommodation(accommodation.id, trip.id)!;
const { mirror } = svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' }) as any;
// The night is last now. The road out of it has no leg left and goes; the roads
// behind B and C follow them one number up, and the one into the next day is
// C's no longer.
// The night is last now. The road out of it has no leg left and goes; the road
// behind B follows it one number up. The one behind C was the drive into the next
// day, which leaves from the hotel now, so it goes as well rather than bending the
// drive from C to the hotel through a point on the road to tomorrow.
expect(stopsOn(day.id)).toEqual([a.id, b.id, c.id, hotel.id]);
expect(viaAnchors(day.id)).toEqual([
{ id: afterA, after_order_index: 0 },
{ id: afterB, after_order_index: 1 },
{ id: afterC, after_order_index: 2 },
]);
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterHotel);
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterC);
expect(mirror.moved).not.toBeNull();
expect(mirror.vias).toEqual([{ dayId: day.id, vias: expect.arrayContaining([expect.objectContaining({ id: afterB, after_order_index: 1 })]) }]);
});
@@ -574,9 +574,10 @@ describe('updateTime', () => {
});
it('ASG-SVC-042: a sort that moves the last stop up the day takes the drive behind it along, never leaving it on no stop', () => {
// The rule the planner applies too (carriedSeam): a via behind the last stop stays
// there only while that stop is last. Once another stop is, the via follows its stop
// like any other, and nothing is left on an index behind the day's end.
// The rule the planner applies too (seamViaIndex): a via behind the last stop stays
// there only while that stop is last. Once another stop is, it goes: it lies on the
// road to tomorrow, and read as the leg its stop leaves by now it would bend the
// drive from C to A through a point on that road.
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], ['12:00', 1], ['18:00', 2]]);
const afterA = addVia(day.id, 0);
const intoTomorrow = addVia(day.id, 2);
@@ -584,10 +585,8 @@ describe('updateTime', () => {
const update = svc.updateTime(c, '08:00', null);
expect(dayOrder(day.id)).toEqual([c, a, b]);
expect(viaAnchors(day.id)).toEqual([
{ id: afterA, after_order_index: 1, sequence: 0 },
{ id: intoTomorrow, after_order_index: 0, sequence: 0 },
]);
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(intoTomorrow);
expect(update.vias?.dayId).toBe(day.id);
});
+9 -2
View File
@@ -218,9 +218,16 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.quicker': 'أسرع بـ {time}',
'roadtrip.alt.otherEngine': 'الزمن محسوب بمحرك التجنب، لا بالمحرك الرئيسي.',
'roadtrip.alt.notHeld': 'خادم المسارات للرحلة البرية لا يتبع هذا الطريق، لذلك لم يُحفظ.',
'roadtrip.alt.ferryNotHeld': 'يعبر هذا الطريق بالعبّارة. أضف العبّارة كحجز نقل وستتبعها الرحلة.',
'roadtrip.alt.ferryNotHeld':
'يعبر هذا الطريق بالعبّارة. أضف العبّارة كحجز نقل وستتبعها الرحلة. وإن كانت تصل في اليوم التالي، فضع المحطات الواقعة على الضفة الأخرى في ذلك اليوم.',
'roadtrip.alt.legChanged': 'تغيّرت هذه المرحلة أثناء التحقق، لذلك لم يُحفظ أي شيء.',
'roadtrip.alt.offline': 'تُحفظ الطرق الأخرى عبر الإنترنت فقط. أعد الاتصال لاختيار هذا الطريق.',
'roadtrip.alt.otherEngineStandard': 'الزمن محسوب بالمحرك الرئيسي، لا بالمحرك الذي تُخطَّط به هذه المرحلة.',
'roadtrip.alt.avoidNotHeld':
'لا تسلك الرحلة البرية هذا الطريق إلا إذا فُعّل خيار «{class}» ضمن «{setting}» في إعداداتها.',
'roadtrip.alt.checking': 'يجري التحقق من هذا الطريق عبر خادم مسارات الرحلة البرية…',
'roadtrip.alt.standIn':
'لم يستجب محرك التجنب لهذه المرحلة، لذلك رسمها المحرك الرئيسي. يجري سؤال محرك التجنب مرة أخرى الآن.',
'roadtrip.day': 'اليوم {number}',
'roadtrip.quietDay.empty': 'لا توجد محطات بعد — أفلِت واحدة هنا',
'roadtrip.quietDay.one': 'حتى الآن {name} فقط — أفلِت محطة هنا',
@@ -274,7 +281,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'الاستلام {time}',
'roadtrip.ride.return': 'الإرجاع {time}',
'roadtrip.ride.open': 'فتح الحجز',
'roadtrip.ride.undated': 'لا يوجد تاريخ للحجز {title}، لذلك لا يستخدمه المسار.',
'roadtrip.ride.undated': 'الحجز {title} لا يقع في أي يوم من أيام هذه الرحلة، لذلك لا يستخدمه المسار.',
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
};
export default roadtrip;
+9 -2
View File
@@ -224,9 +224,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Tempo do roteador de desvios, não do principal.',
'roadtrip.alt.notHeld': 'O roteador da viagem não segue este caminho, então ele não foi salvo.',
'roadtrip.alt.ferryNotHeld':
'Este caminho atravessa de balsa. Adicione a balsa como reserva de transporte e o trajeto passa a segui-la.',
'Este caminho atravessa de balsa. Adicione a balsa como reserva de transporte e o trajeto passa a segui-la. Se ela chega no dia seguinte, coloque as paradas do outro lado da água nesse dia.',
'roadtrip.alt.legChanged': 'Este trecho mudou enquanto o caminho era verificado, então nada foi salvo.',
'roadtrip.alt.offline': 'Outros caminhos só são salvos online. Reconecte-se para escolher este.',
'roadtrip.alt.otherEngineStandard': 'Tempo do roteador principal, não daquele com que este trecho é planejado.',
'roadtrip.alt.avoidNotHeld':
'A viagem só faz este caminho com “{class}” marcado em “{setting}” nas configurações dela.',
'roadtrip.alt.checking': 'Verificando este caminho com o roteador da viagem…',
'roadtrip.alt.standIn':
'O roteador de desvios não respondeu para este trecho, então o roteador principal o desenhou. O roteador de desvios está sendo consultado de novo.',
'roadtrip.day': 'Dia {number}',
'roadtrip.quietDay.empty': 'Ainda sem paradas — solte uma aqui',
'roadtrip.quietDay.one': 'Até agora só {name} — solte uma parada aqui',
@@ -280,7 +286,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Retirada {time}',
'roadtrip.ride.return': 'Devolução {time}',
'roadtrip.ride.open': 'Abrir reserva',
'roadtrip.ride.undated': 'A reserva {title} está sem data, por isso o percurso não a considera.',
'roadtrip.ride.undated':
'A reserva {title} não está em nenhum dia desta viagem, por isso o percurso não a considera.',
'roadtrip.bookings.loose': 'Também reservado neste dia',
};
export default roadtrip;
+9 -2
View File
@@ -226,9 +226,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Temps de l’encaminador d’evitació, no del principal.',
'roadtrip.alt.notHeld': 'L’encaminador del viatge no segueix aquest camí, així que no s’ha desat.',
'roadtrip.alt.ferryNotHeld':
'Aquest camí creua en transbordador. Afegeix el transbordador com a reserva de transport i el trajecte el seguirà.',
'Aquest camí creua en transbordador. Afegeix el transbordador com a reserva de transport i el trajecte el seguirà. Si arriba l’endemà, posa les parades de l’altra banda de l’aigua en aquell dia.',
'roadtrip.alt.legChanged': 'Aquest tram ha canviat mentre es comprovava el camí, així que no s’ha desat res.',
'roadtrip.alt.offline': 'Els altres camins només es desen amb connexió. Torna a connectar-te per triar aquest.',
'roadtrip.alt.otherEngineStandard': 'Temps de l’encaminador principal, no del que planifica aquest tram.',
'roadtrip.alt.avoidNotHeld':
'El viatge només fa aquest camí si «{class}» està marcat a «{setting}» a la seva configuració.',
'roadtrip.alt.checking': 'S’està comprovant aquest camí amb l’encaminador del viatge…',
'roadtrip.alt.standIn':
'L’encaminador d’evitació no ha respost per a aquest tram, així que l’ha dibuixat l’encaminador principal. S’està tornant a consultar l’encaminador d’evitació.',
'roadtrip.day': 'Dia {number}',
'roadtrip.quietDay.empty': 'Encara sense parades: deixa’n una aquí',
'roadtrip.quietDay.one': 'De moment només {name}: deixa una parada aquí',
@@ -283,7 +289,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Recollida {time}',
'roadtrip.ride.return': 'Devolució {time}',
'roadtrip.ride.open': 'Obre la reserva',
'roadtrip.ride.undated': 'La reserva {title} no té data, així que el trajecte no la té en compte.',
'roadtrip.ride.undated':
'La reserva {title} no és a cap dia d’aquest viatge, així que el trajecte no la té en compte.',
'roadtrip.bookings.loose': 'També reservat aquest dia',
};
export default roadtrip;
+8 -2
View File
@@ -222,9 +222,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Čas spočítal server pro objížďky, ne ten hlavní.',
'roadtrip.alt.notHeld': 'Směrovací server cesty touto trasou nepovede, proto nebyla uložena.',
'roadtrip.alt.ferryNotHeld':
'Tato trasa vede přes trajekt. Přidej trajekt jako rezervaci dopravy a jízda ho bude následovat.',
'Tato trasa vede přes trajekt. Přidej trajekt jako rezervaci dopravy a jízda ho bude následovat. Pokud připlouvá až další den, dej zastávky na druhém břehu na ten den.',
'roadtrip.alt.legChanged': 'Úsek se během kontroly změnil, proto se nic neuložilo.',
'roadtrip.alt.offline': 'Jiné trasy se ukládají jen online. Pro výběr této trasy se znovu připoj.',
'roadtrip.alt.otherEngineStandard': 'Čas spočítal hlavní server, ne ten, kterým se tento úsek plánuje.',
'roadtrip.alt.avoidNotHeld':
'Touto trasou cesta pojede jen tehdy, když je v jejím nastavení v části „{setting}“ zaškrtnuto „{class}“.',
'roadtrip.alt.checking': 'Směrovací server cesty tuto trasu ověřuje…',
'roadtrip.alt.standIn':
'Server pro objížďky pro tento úsek neodpověděl, takže ho nakreslil hlavní server. Dotaz na server pro objížďky se teď opakuje.',
'roadtrip.day': 'Den {number}',
'roadtrip.quietDay.empty': 'Zatím žádné zastávky — přetáhněte sem',
'roadtrip.quietDay.one': 'Zatím jen {name} — přetáhněte sem zastávku',
@@ -278,7 +284,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Vyzvednutí {time}',
'roadtrip.ride.return': 'Vrácení {time}',
'roadtrip.ride.open': 'Otevřít rezervaci',
'roadtrip.ride.undated': 'Rezervace {title} nemá datum, proto s ní jízda nepočítá.',
'roadtrip.ride.undated': 'Rezervace {title} nepatří k žádnému dni této cesty, proto s ní jízda nepočítá.',
'roadtrip.bookings.loose': 'Také rezervováno na tento den',
};
export default roadtrip;
+9 -2
View File
@@ -221,9 +221,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Zeit vom Ausweich-Router, nicht vom Haupt-Router.',
'roadtrip.alt.notHeld': 'Der Router des Roadtrips fährt diesen Weg nicht nach, deshalb wurde er nicht gespeichert.',
'roadtrip.alt.ferryNotHeld':
'Dieser Weg führt über eine Fähre. Trag die Fähre als Transportbuchung ein, dann folgt ihr die Fahrt.',
'Dieser Weg führt über eine Fähre. Trag die Fähre als Transportbuchung ein, dann folgt ihr die Fahrt. Legt sie erst am nächsten Tag an, gehören die Stopps auf der anderen Seite des Wassers auf diesen Tag.',
'roadtrip.alt.legChanged': 'Die Etappe hat sich während der Prüfung geändert, deshalb wurde nichts gespeichert.',
'roadtrip.alt.offline': 'Andere Wege werden nur online gespeichert. Verbinde dich wieder, um diesen zu nehmen.',
'roadtrip.alt.otherEngineStandard': 'Zeit vom Haupt-Router, nicht von dem, mit dem diese Etappe geplant wird.',
'roadtrip.alt.avoidNotHeld':
'Diesen Weg fährt der Roadtrip nur, wenn in seinen Einstellungen unter „{setting}“ die Option „{class}“ angehakt ist.',
'roadtrip.alt.checking': 'Der Router des Roadtrips prüft diesen Weg…',
'roadtrip.alt.standIn':
'Der Ausweich-Router hat für diese Etappe nicht geantwortet, deshalb hat der Haupt-Router sie gezeichnet. Der Ausweich-Router wird jetzt noch einmal gefragt.',
'roadtrip.day': 'Tag {number}',
'roadtrip.quietDay.empty': 'Noch keine Stopps — hier ablegen',
'roadtrip.quietDay.one': 'Bisher nur {name} — hier einen Stopp ablegen',
@@ -277,7 +283,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Abholung {time}',
'roadtrip.ride.return': 'Rückgabe {time}',
'roadtrip.ride.open': 'Buchung öffnen',
'roadtrip.ride.undated': 'Die Buchung {title} hat kein Datum, deshalb plant die Fahrt nicht mit ihr.',
'roadtrip.ride.undated':
'Die Buchung {title} liegt auf keinem Tag dieser Reise, deshalb plant die Fahrt nicht mit ihr.',
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
};
export default roadtrip;
+8 -2
View File
@@ -219,9 +219,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Timed by the avoidance router, not the main one.',
'roadtrip.alt.notHeld': 'The road trip’s router won’t follow this way, so it was not saved.',
'roadtrip.alt.ferryNotHeld':
'This way crosses by ferry. Add the ferry as a transport booking and the drive follows it.',
'This way crosses by ferry. Add the ferry as a transport booking and the drive follows it. If it lands the next day, put the stops across the water on that day.',
'roadtrip.alt.legChanged': 'This leg changed while the way was being checked, so nothing was saved.',
'roadtrip.alt.offline': 'Other ways are saved online only. Reconnect to take this one.',
'roadtrip.alt.otherEngineStandard': 'Timed by the main router, not by the one this leg is planned with.',
'roadtrip.alt.avoidNotHeld':
'The road trip only drives this way with “{class}” ticked under “{setting}” in its settings.',
'roadtrip.alt.checking': 'Checking this way with the road trip’s router…',
'roadtrip.alt.standIn':
'The avoidance router did not answer for this leg, so the main router drew it. It is being asked again.',
'roadtrip.day': 'Day {number}',
'roadtrip.quietDay.empty': 'No stops yet — drop one here',
'roadtrip.quietDay.one': 'Only {name} so far — drop a stop here',
@@ -275,7 +281,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Pick-up {time}',
'roadtrip.ride.return': 'Return {time}',
'roadtrip.ride.open': 'Open booking',
'roadtrip.ride.undated': '{title} has no date, so the drive does not use it.',
'roadtrip.ride.undated': '{title} is on none of this trip’s days, so the drive does not use it.',
'roadtrip.bookings.loose': 'Also booked this day',
};
export default roadtrip;
+8 -2
View File
@@ -225,9 +225,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Tiempo del enrutador de evitación, no del principal.',
'roadtrip.alt.notHeld': 'El enrutador del viaje no sigue esta ruta, así que no se ha guardado.',
'roadtrip.alt.ferryNotHeld':
'Esta ruta cruza en ferri. Añade el ferri como reserva de transporte y el trayecto lo seguirá.',
'Esta ruta cruza en ferri. Añade el ferri como reserva de transporte y el trayecto lo seguirá. Si llega al día siguiente, pon las paradas del otro lado del agua en ese día.',
'roadtrip.alt.legChanged': 'Este tramo cambió mientras se comprobaba la ruta, así que no se ha guardado nada.',
'roadtrip.alt.offline': 'Las otras rutas solo se guardan con conexión. Vuelve a conectarte para elegir esta.',
'roadtrip.alt.otherEngineStandard': 'Tiempo del enrutador principal, no del que planifica este tramo.',
'roadtrip.alt.avoidNotHeld': 'El viaje solo hace esta ruta con «{class}» marcado en «{setting}» en su configuración.',
'roadtrip.alt.checking': 'Comprobando esta ruta con el enrutador del viaje…',
'roadtrip.alt.standIn':
'El enrutador de evitación no respondió para este tramo, así que lo dibujó el enrutador principal. Se está volviendo a consultar el enrutador de evitación.',
'roadtrip.day': 'Día {number}',
'roadtrip.quietDay.empty': 'Sin paradas todavía: suelta una aquí',
'roadtrip.quietDay.one': 'Por ahora solo {name}: suelta una parada aquí',
@@ -282,7 +287,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Recogida {time}',
'roadtrip.ride.return': 'Devolución {time}',
'roadtrip.ride.open': 'Abrir reserva',
'roadtrip.ride.undated': 'La reserva {title} no tiene fecha, así que el trayecto no la tiene en cuenta.',
'roadtrip.ride.undated':
'La reserva {title} no está en ningún día de este viaje, así que el trayecto no la tiene en cuenta.',
'roadtrip.bookings.loose': 'También reservado este día',
};
export default roadtrip;
+9 -2
View File
@@ -225,10 +225,16 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Temps du routeur de contournement, pas du principal.',
'roadtrip.alt.notHeld': 'Le routeur du road trip ne suit pas cet itinéraire, il n’a donc pas été enregistré.',
'roadtrip.alt.ferryNotHeld':
'Cet itinéraire traverse en ferry. Ajoutez le ferry comme réservation de transport et le trajet le suivra.',
'Cet itinéraire traverse en ferry. Ajoutez le ferry comme réservation de transport et le trajet le suivra. S’il arrive le lendemain, placez les arrêts de l’autre côté de l’eau ce jour-là.',
'roadtrip.alt.legChanged': 'Cette étape a changé pendant la vérification, rien n’a donc été enregistré.',
'roadtrip.alt.offline':
'Les autres itinéraires ne s’enregistrent qu’en ligne. Reconnectez-vous pour choisir celui-ci.',
'roadtrip.alt.otherEngineStandard': 'Temps du routeur principal, pas de celui qui planifie cette étape.',
'roadtrip.alt.avoidNotHeld':
'Le road trip ne prend cet itinéraire que si « {class} » est coché sous « {setting} » dans ses réglages.',
'roadtrip.alt.checking': 'Vérification de cet itinéraire auprès du routeur du road trip…',
'roadtrip.alt.standIn':
'Le routeur de contournement n’a pas répondu pour cette étape, le routeur principal l’a donc tracée. Le routeur de contournement est interrogé à nouveau.',
'roadtrip.day': 'Jour {number}',
'roadtrip.quietDay.empty': 'Aucun arrêt — déposez-en un ici',
'roadtrip.quietDay.one': 'Seulement {name} pour l’instant — déposez un arrêt ici',
@@ -283,7 +289,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Prise en charge {time}',
'roadtrip.ride.return': 'Restitution {time}',
'roadtrip.ride.open': 'Ouvrir la réservation',
'roadtrip.ride.undated': 'La réservation {title} n’a pas de date, le trajet n’en tient donc pas compte.',
'roadtrip.ride.undated':
'La réservation {title} n’est sur aucun jour de ce voyage, le trajet n’en tient donc pas compte.',
'roadtrip.bookings.loose': 'Également réservé ce jour',
};
export default roadtrip;
+10 -2
View File
@@ -227,9 +227,16 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Χρόνος από τον δρομολογητή αποφυγής, όχι τον κύριο.',
'roadtrip.alt.notHeld': 'Ο δρομολογητής του ταξιδιού δεν ακολουθεί αυτή τη διαδρομή, οπότε δεν αποθηκεύτηκε.',
'roadtrip.alt.ferryNotHeld':
'Αυτή η διαδρομή περνά με πλοίο. Πρόσθεσε το πλοίο ως κράτηση μεταφοράς και η οδήγηση θα το ακολουθήσει.',
'Αυτή η διαδρομή περνά με πλοίο. Πρόσθεσε το πλοίο ως κράτηση μεταφοράς και η οδήγηση θα το ακολουθήσει. Αν φτάνει την επόμενη μέρα, βάλε τις στάσεις στην απέναντι ακτή σε εκείνη τη μέρα.',
'roadtrip.alt.legChanged': 'Το σκέλος άλλαξε κατά τον έλεγχο, οπότε δεν αποθηκεύτηκε τίποτα.',
'roadtrip.alt.offline': 'Οι άλλες διαδρομές αποθηκεύονται μόνο online. Συνδέσου ξανά για να επιλέξεις αυτή.',
'roadtrip.alt.otherEngineStandard':
'Χρόνος από τον κύριο δρομολογητή, όχι από αυτόν με τον οποίο σχεδιάζεται αυτό το σκέλος.',
'roadtrip.alt.avoidNotHeld':
'Το ταξίδι ακολουθεί αυτή τη διαδρομή μόνο αν είναι επιλεγμένο το «{class}» στο «{setting}» στις ρυθμίσεις του.',
'roadtrip.alt.checking': 'Έλεγχος αυτής της διαδρομής με τον δρομολογητή του ταξιδιού…',
'roadtrip.alt.standIn':
'Ο δρομολογητής αποφυγής δεν απάντησε για αυτό το σκέλος, οπότε το σχεδίασε ο κύριος δρομολογητής. Ο δρομολογητής αποφυγής ερωτάται ξανά.',
'roadtrip.day': 'Ημέρα {number}',
'roadtrip.quietDay.empty': 'Καμία στάση ακόμη — αφήστε μία εδώ',
'roadtrip.quietDay.one': 'Μέχρι στιγμής μόνο {name} — αφήστε μια στάση εδώ',
@@ -284,7 +291,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Παραλαβή {time}',
'roadtrip.ride.return': 'Επιστροφή {time}',
'roadtrip.ride.open': 'Άνοιγμα κράτησης',
'roadtrip.ride.undated': 'Η κράτηση {title} δεν έχει ημερομηνία, οπότε η διαδρομή δεν τη λαμβάνει υπόψη.',
'roadtrip.ride.undated':
'Η κράτηση {title} δεν βρίσκεται σε καμία μέρα αυτού του ταξιδιού, οπότε η διαδρομή δεν τη λαμβάνει υπόψη.',
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
};
export default roadtrip;
+9 -2
View File
@@ -223,9 +223,16 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Az időt a kerülő útvonaltervező számolta, nem a fő.',
'roadtrip.alt.notHeld': 'Az autós út útvonaltervezője nem követi ezt az utat, ezért nem mentettük el.',
'roadtrip.alt.ferryNotHeld':
'Ez az út komppal kel át. Add hozzá a kompot közlekedési foglalásként, és az út követni fogja.',
'Ez az út komppal kel át. Add hozzá a kompot közlekedési foglalásként, és az út követni fogja. Ha csak másnap köt ki, a víz túloldalán lévő megállókat tedd arra a napra.',
'roadtrip.alt.legChanged': 'A szakasz az ellenőrzés közben megváltozott, ezért semmi sem lett mentve.',
'roadtrip.alt.offline': 'A többi utat csak online lehet menteni. Csatlakozz újra, hogy ezt válaszd.',
'roadtrip.alt.otherEngineStandard':
'Az időt a fő útvonaltervező számolta, nem az, amellyel ezt a szakaszt tervezzük.',
'roadtrip.alt.avoidNotHeld':
'Az autós út csak akkor megy ezen az úton, ha a beállításaiban a „{setting}” alatt be van jelölve a „{class}”.',
'roadtrip.alt.checking': 'Az autós út útvonaltervezője ellenőrzi ezt az utat…',
'roadtrip.alt.standIn':
'A kerülő útvonaltervező nem válaszolt erre a szakaszra, ezért a fő útvonaltervező rajzolta meg. A kerülő útvonaltervezőt most újra megkérdezzük.',
'roadtrip.day': '{number}. nap',
'roadtrip.quietDay.empty': 'Még nincs megálló — húzzon ide egyet',
'roadtrip.quietDay.one': 'Egyelőre csak {name} — húzzon ide egy megállót',
@@ -279,7 +286,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Felvétel {time}',
'roadtrip.ride.return': 'Visszaadás {time}',
'roadtrip.ride.open': 'Foglalás megnyitása',
'roadtrip.ride.undated': '{title}: a foglalásnak nincs dátuma, ezért az útvonal nem számol vele.',
'roadtrip.ride.undated': '{title}: a foglalás az utazás egyik napjára sem esik, ezért az útvonal nem számol vele.',
'roadtrip.bookings.loose': 'Erre a napra még lefoglalva',
};
export default roadtrip;
+9 -2
View File
@@ -225,9 +225,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Waktu dihitung mesin penghindaran, bukan mesin utama.',
'roadtrip.alt.notHeld': 'Server rute perjalanan tidak mengikuti jalan ini, jadi jalan ini tidak disimpan.',
'roadtrip.alt.ferryNotHeld':
'Jalan ini menyeberang dengan feri. Tambahkan feri sebagai pemesanan transportasi dan perjalanan akan mengikutinya.',
'Jalan ini menyeberang dengan feri. Tambahkan feri sebagai pemesanan transportasi dan perjalanan akan mengikutinya. Jika feri tiba keesokan harinya, taruh perhentian di seberang pada hari itu.',
'roadtrip.alt.legChanged': 'Ruas ini berubah saat jalannya diperiksa, jadi tidak ada yang disimpan.',
'roadtrip.alt.offline': 'Jalan lain hanya disimpan saat online. Sambungkan kembali untuk memilih yang ini.',
'roadtrip.alt.otherEngineStandard': 'Waktu dihitung mesin utama, bukan mesin yang merencanakan ruas ini.',
'roadtrip.alt.avoidNotHeld':
'Perjalanan hanya melewati jalan ini jika “{class}” dicentang di bawah “{setting}” dalam pengaturannya.',
'roadtrip.alt.checking': 'Memeriksa jalan ini dengan server rute perjalanan…',
'roadtrip.alt.standIn':
'Mesin penghindaran tidak menjawab untuk ruas ini, jadi mesin utama yang menggambarnya. Mesin penghindaran sedang ditanya lagi.',
'roadtrip.day': 'Hari {number}',
'roadtrip.quietDay.empty': 'Belum ada perhentian — jatuhkan di sini',
'roadtrip.quietDay.one': 'Sejauh ini hanya {name} — jatuhkan perhentian di sini',
@@ -282,7 +288,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Pengambilan {time}',
'roadtrip.ride.return': 'Pengembalian {time}',
'roadtrip.ride.open': 'Buka pemesanan',
'roadtrip.ride.undated': 'Pemesanan {title} tidak memiliki tanggal, jadi perjalanan tidak memperhitungkannya.',
'roadtrip.ride.undated':
'Pemesanan {title} tidak berada di hari mana pun dalam perjalanan ini, jadi perjalanan tidak memperhitungkannya.',
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
};
export default roadtrip;
+9 -2
View File
@@ -225,9 +225,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Tempo dal router delle esclusioni, non dal principale.',
'roadtrip.alt.notHeld': 'Il router del viaggio non segue questo percorso, quindi non è stato salvato.',
'roadtrip.alt.ferryNotHeld':
'Questo percorso attraversa in traghetto. Aggiungi il traghetto come prenotazione di trasporto e il tragitto lo seguirà.',
'Questo percorso attraversa in traghetto. Aggiungi il traghetto come prenotazione di trasporto e il tragitto lo seguirà. Se arriva il giorno dopo, metti le tappe sull’altra sponda in quel giorno.',
'roadtrip.alt.legChanged': 'La tratta è cambiata durante la verifica, quindi non è stato salvato nulla.',
'roadtrip.alt.offline': 'Gli altri percorsi si salvano solo online. Riconnettiti per scegliere questo.',
'roadtrip.alt.otherEngineStandard': 'Tempo dal router principale, non da quello con cui è pianificata questa tratta.',
'roadtrip.alt.avoidNotHeld':
'Il viaggio segue questo percorso solo con «{class}» spuntato in «{setting}» nelle sue impostazioni.',
'roadtrip.alt.checking': 'Verifica di questo percorso con il router del viaggio…',
'roadtrip.alt.standIn':
'Il router delle esclusioni non ha risposto per questa tratta, quindi l’ha disegnata il router principale. Il router delle esclusioni viene interrogato di nuovo.',
'roadtrip.day': 'Giorno {number}',
'roadtrip.quietDay.empty': 'Ancora nessuna sosta: trascinane una qui',
'roadtrip.quietDay.one': 'Finora solo {name}: trascina qui una sosta',
@@ -282,7 +288,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Ritiro {time}',
'roadtrip.ride.return': 'Riconsegna {time}',
'roadtrip.ride.open': 'Apri prenotazione',
'roadtrip.ride.undated': 'La prenotazione {title} non ha una data, quindi il percorso non la considera.',
'roadtrip.ride.undated':
'La prenotazione {title} non cade in nessun giorno di questo viaggio, quindi il percorso non la considera.',
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
};
export default roadtrip;
+8 -2
View File
@@ -221,9 +221,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': '所要時間は主エンジンではなく回避用エンジンでの計算です。',
'roadtrip.alt.notHeld': 'ロードトリップのルーティングサーバーがこの道をたどらないため、保存されませんでした。',
'roadtrip.alt.ferryNotHeld':
'この道はフェリーで渡ります。フェリーを交通手段の予約として追加すると、ルートがそれに沿います。',
'この道はフェリーで渡ります。フェリーを交通手段の予約として追加すると、ルートがそれに沿います。到着が翌日の場合は、対岸の立ち寄り先をその日に移してください。',
'roadtrip.alt.legChanged': '確認中にこの区間が変更されたため、何も保存されませんでした。',
'roadtrip.alt.offline': 'ほかの道はオンラインでのみ保存できます。この道を選ぶには再接続してください。',
'roadtrip.alt.otherEngineStandard': '所要時間は、この区間の計画に使うエンジンではなく主エンジンでの計算です。',
'roadtrip.alt.avoidNotHeld':
'このルートを通るのは、設定の「{setting}」で「{class}」にチェックが入っている場合だけです。',
'roadtrip.alt.checking': 'ロードトリップのルーティングサーバーでこの道を確認しています…',
'roadtrip.alt.standIn':
'この区間では回避用エンジンが応答しなかったため、主エンジンが描画しました。回避用エンジンにもう一度問い合わせています。',
'roadtrip.day': '{number} 日目',
'roadtrip.quietDay.empty': 'まだ立ち寄り先がありません。ここにドロップ',
'roadtrip.quietDay.one': '今のところ {name} だけです。立ち寄り先をここにドロップ',
@@ -277,7 +283,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': '受取 {time}',
'roadtrip.ride.return': '返却 {time}',
'roadtrip.ride.open': '予約を開く',
'roadtrip.ride.undated': '{title} には日付がないため、走行では使われません。',
'roadtrip.ride.undated': '{title} はこの旅行のどの日にも入っていないため、走行では使われません。',
'roadtrip.bookings.loose': 'この日のその他の予約',
};
export default roadtrip;
+8 -2
View File
@@ -218,9 +218,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.quicker': '{time} 더 빠름',
'roadtrip.alt.otherEngine': '기본 엔진이 아니라 회피 엔진이 계산한 시간입니다.',
'roadtrip.alt.notHeld': '로드트립의 경로 서버가 이 길을 따르지 않아 저장하지 않았습니다.',
'roadtrip.alt.ferryNotHeld': '이 길은 페리로 건넙니다. 페리를 교통편 예약으로 추가하면 경로가 그대로 따라갑니다.',
'roadtrip.alt.ferryNotHeld':
'이 길은 페리로 건넙니다. 페리를 교통편 예약으로 추가하면 경로가 그대로 따라갑니다. 다음 날 도착한다면 건너편의 경유지를 그날로 옮기세요.',
'roadtrip.alt.legChanged': '확인하는 동안 이 구간이 바뀌어 아무것도 저장하지 않았습니다.',
'roadtrip.alt.offline': '다른 길은 온라인에서만 저장됩니다. 이 길을 고르려면 다시 연결하세요.',
'roadtrip.alt.otherEngineStandard': '이 구간을 계획하는 엔진이 아니라 기본 엔진이 계산한 시간입니다.',
'roadtrip.alt.avoidNotHeld': '설정의 “{setting}”에서 “{class}”을(를) 선택해야 로드트립이 이 길로 갑니다.',
'roadtrip.alt.checking': '로드트립의 경로 서버로 이 길을 확인하는 중…',
'roadtrip.alt.standIn':
'이 구간에서 회피 엔진이 응답하지 않아 기본 엔진이 경로를 그렸습니다. 회피 엔진에 다시 묻는 중입니다.',
'roadtrip.day': '{number}일차',
'roadtrip.quietDay.empty': '아직 경유지가 없습니다. 여기에 놓으세요',
'roadtrip.quietDay.one': '지금은 {name}뿐입니다. 경유지를 여기에 놓으세요',
@@ -274,7 +280,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': '픽업 {time}',
'roadtrip.ride.return': '반납 {time}',
'roadtrip.ride.open': '예약 열기',
'roadtrip.ride.undated': '{title}에 날짜가 없어 주행에 반영되지 않습니다.',
'roadtrip.ride.undated': '{title}은(는) 이 여행의 어느 날에도 속하지 않아 주행에 반영되지 않습니다.',
'roadtrip.bookings.loose': '이날의 다른 예약',
};
export default roadtrip;
+9 -2
View File
@@ -225,9 +225,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Tijd van de vermijdingsrouter, niet van de hoofdrouter.',
'roadtrip.alt.notHeld': 'De routeserver van de roadtrip volgt deze route niet, dus die is niet opgeslagen.',
'roadtrip.alt.ferryNotHeld':
'Deze route gaat met de veerboot. Voeg de veerboot toe als vervoersboeking en de rit volgt hem.',
'Deze route gaat met de veerboot. Voeg de veerboot toe als vervoersboeking en de rit volgt hem. Komt hij pas de volgende dag aan, zet de stops aan de overkant dan op die dag.',
'roadtrip.alt.legChanged': 'Dit traject veranderde tijdens de controle, dus er is niets opgeslagen.',
'roadtrip.alt.offline': 'Andere routes worden alleen online opgeslagen. Maak opnieuw verbinding om deze te kiezen.',
'roadtrip.alt.otherEngineStandard': 'Tijd van de hoofdrouter, niet van de router waarmee dit traject gepland wordt.',
'roadtrip.alt.avoidNotHeld':
'De roadtrip rijdt deze route alleen als ‘{class}’ is aangevinkt onder ‘{setting}’ in de instellingen.',
'roadtrip.alt.checking': 'Deze route wordt gecontroleerd met de router van de roadtrip…',
'roadtrip.alt.standIn':
'De vermijdingsrouter gaf voor dit traject geen antwoord, dus de hoofdrouter heeft het getekend. De vermijdingsrouter wordt opnieuw gevraagd.',
'roadtrip.day': 'Dag {number}',
'roadtrip.quietDay.empty': 'Nog geen stops — laat er hier een vallen',
'roadtrip.quietDay.one': 'Voorlopig alleen {name} — laat hier een stop vallen',
@@ -281,7 +287,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Ophalen {time}',
'roadtrip.ride.return': 'Inleveren {time}',
'roadtrip.ride.open': 'Boeking openen',
'roadtrip.ride.undated': 'De boeking {title} heeft geen datum, dus de rit houdt er geen rekening mee.',
'roadtrip.ride.undated':
'De boeking {title} valt op geen enkele dag van deze reis, dus de rit houdt er geen rekening mee.',
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
};
export default roadtrip;
+8 -2
View File
@@ -223,9 +223,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Czas policzony silnikiem omijania, nie głównym.',
'roadtrip.alt.notHeld': 'Serwer tras podróży nie poprowadzi tą drogą, więc nie została zapisana.',
'roadtrip.alt.ferryNotHeld':
'Ta droga prowadzi promem. Dodaj prom jako rezerwację transportu, a trasa za nim pójdzie.',
'Ta droga prowadzi promem. Dodaj prom jako rezerwację transportu, a trasa za nim pójdzie. Jeśli przypływa następnego dnia, przenieś przystanki z drugiego brzegu na ten dzień.',
'roadtrip.alt.legChanged': 'Ten odcinek zmienił się podczas sprawdzania, więc nic nie zostało zapisane.',
'roadtrip.alt.offline': 'Inne drogi zapisują się tylko online. Połącz się ponownie, aby wybrać tę.',
'roadtrip.alt.otherEngineStandard': 'Czas policzony silnikiem głównym, nie tym, którym planowany jest ten odcinek.',
'roadtrip.alt.avoidNotHeld':
'Podróż pojedzie tą drogą tylko wtedy, gdy w jej ustawieniach w sekcji „{setting}” zaznaczono „{class}”.',
'roadtrip.alt.checking': 'Sprawdzanie tej drogi w serwerze tras podróży…',
'roadtrip.alt.standIn':
'Silnik omijania nie odpowiedział dla tego odcinka, więc narysował go silnik główny. Silnik omijania jest pytany ponownie.',
'roadtrip.day': 'Dzień {number}',
'roadtrip.quietDay.empty': 'Brak przystanków — upuść tutaj',
'roadtrip.quietDay.one': 'Na razie tylko {name} — upuść tutaj przystanek',
@@ -279,7 +285,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Odbiór {time}',
'roadtrip.ride.return': 'Zwrot {time}',
'roadtrip.ride.open': 'Otwórz rezerwację',
'roadtrip.ride.undated': 'Rezerwacja {title} nie ma daty, więc trasa jej nie uwzględnia.',
'roadtrip.ride.undated': 'Rezerwacja {title} nie przypada na żaden dzień tej podróży, więc trasa jej nie uwzględnia.',
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
};
export default roadtrip;
+10 -2
View File
@@ -225,9 +225,16 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Время посчитал маршрутизатор обхода, не основной.',
'roadtrip.alt.notHeld': 'Маршрутизатор поездки не поведёт по этому пути, поэтому он не сохранён.',
'roadtrip.alt.ferryNotHeld':
'Этот путь проходит на пароме. Добавьте паром как бронирование транспорта, и маршрут пойдёт по нему.',
'Этот путь проходит на пароме. Добавьте паром как бронирование транспорта, и маршрут пойдёт по нему. Если он прибывает на следующий день, перенесите остановки на другом берегу на этот день.',
'roadtrip.alt.legChanged': 'Участок изменился во время проверки, поэтому ничего не сохранено.',
'roadtrip.alt.offline': 'Другие пути сохраняются только онлайн. Подключитесь снова, чтобы выбрать этот.',
'roadtrip.alt.otherEngineStandard':
'Время посчитал основной маршрутизатор, а не тот, которым планируется этот участок.',
'roadtrip.alt.avoidNotHeld':
'Поездка пойдёт этим путём, только если в её настройках в разделе «{setting}» отмечено «{class}».',
'roadtrip.alt.checking': 'Маршрутизатор поездки проверяет этот путь…',
'roadtrip.alt.standIn':
'Маршрутизатор обхода не ответил для этого участка, поэтому его нарисовал основной маршрутизатор. Маршрутизатор обхода запрашивается снова.',
'roadtrip.day': 'День {number}',
'roadtrip.quietDay.empty': 'Остановок пока нет — перетащите сюда',
'roadtrip.quietDay.one': 'Пока только {name} — перетащите сюда остановку',
@@ -281,7 +288,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Получение {time}',
'roadtrip.ride.return': 'Возврат {time}',
'roadtrip.ride.open': 'Открыть бронирование',
'roadtrip.ride.undated': 'У бронирования {title} нет даты, поэтому маршрут его не учитывает.',
'roadtrip.ride.undated':
'Бронирование {title} не относится ни к одному дню этой поездки, поэтому маршрут его не учитывает.',
'roadtrip.bookings.loose': 'Также забронировано в этот день',
};
export default roadtrip;
+8 -2
View File
@@ -222,9 +222,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Tid från undvikandemotorn, inte huvudmotorn.',
'roadtrip.alt.notHeld': 'Bilresans ruttserver följer inte den här vägen, så den sparades inte.',
'roadtrip.alt.ferryNotHeld':
'Den här vägen går med färja. Lägg till färjan som transportbokning så följer resan den.',
'Den här vägen går med färja. Lägg till färjan som transportbokning så följer resan den. Kommer den fram först nästa dag, lägg stoppen på andra sidan vattnet på den dagen.',
'roadtrip.alt.legChanged': 'Sträckan ändrades medan vägen kontrollerades, så inget sparades.',
'roadtrip.alt.offline': 'Andra vägar sparas bara online. Anslut igen för att välja den här.',
'roadtrip.alt.otherEngineStandard': 'Tid från huvudmotorn, inte från den som den här sträckan planeras med.',
'roadtrip.alt.avoidNotHeld':
'Bilresan kör bara den här vägen om ”{class}” är ikryssat under ”{setting}” i dess inställningar.',
'roadtrip.alt.checking': 'Kontrollerar den här vägen med bilresans ruttserver…',
'roadtrip.alt.standIn':
'Undvikandemotorn svarade inte för den här sträckan, så huvudmotorn ritade den. Undvikandemotorn tillfrågas igen.',
'roadtrip.day': 'Dag {number}',
'roadtrip.quietDay.empty': 'Inga stopp än – släpp ett här',
'roadtrip.quietDay.one': 'Hittills bara {name} – släpp ett stopp här',
@@ -278,7 +284,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Upphämtning {time}',
'roadtrip.ride.return': 'Återlämning {time}',
'roadtrip.ride.open': 'Öppna bokning',
'roadtrip.ride.undated': 'Bokningen {title} har inget datum, så körningen tar inte med den.',
'roadtrip.ride.undated': 'Bokningen {title} ligger inte på någon av resans dagar, så körningen tar inte med den.',
'roadtrip.bookings.loose': 'Även bokat denna dag',
};
export default roadtrip;
+9 -2
View File
@@ -223,9 +223,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.quicker': '{time} daha kısa',
'roadtrip.alt.otherEngine': 'Süre, ana motorla değil kaçınma motoruyla hesaplandı.',
'roadtrip.alt.notHeld': 'Yolculuğun rota sunucusu bu yolu izlemiyor, bu yüzden kaydedilmedi.',
'roadtrip.alt.ferryNotHeld': 'Bu yol feribotla geçiyor. Feribotu ulaşım rezervasyonu olarak ekle, sürüş onu izlesin.',
'roadtrip.alt.ferryNotHeld':
'Bu yol feribotla geçiyor. Feribotu ulaşım rezervasyonu olarak ekle, sürüş onu izlesin. Ertesi gün varıyorsa, karşı kıyıdaki durakları o güne koy.',
'roadtrip.alt.legChanged': 'Bu etap kontrol sırasında değişti, bu yüzden hiçbir şey kaydedilmedi.',
'roadtrip.alt.offline': 'Diğer yollar yalnızca çevrimiçiyken kaydedilir. Bunu seçmek için yeniden bağlan.',
'roadtrip.alt.otherEngineStandard': 'Süre, bu etabın planlandığı motorla değil ana motorla hesaplandı.',
'roadtrip.alt.avoidNotHeld': 'Yolculuk bu yolu yalnızca ayarlarında “{setting}” altında “{class}” işaretliyse izler.',
'roadtrip.alt.checking': 'Bu yol, yolculuğun rota sunucusuyla kontrol ediliyor…',
'roadtrip.alt.standIn':
'Kaçınma motoru bu etap için yanıt vermedi, bu yüzden ana motor çizdi. Kaçınma motoruna yeniden soruluyor.',
'roadtrip.day': '{number}. gün',
'roadtrip.quietDay.empty': 'Henüz mola yok — buraya bırakın',
'roadtrip.quietDay.one': 'Şimdilik yalnızca {name} — buraya bir mola bırakın',
@@ -280,7 +286,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Alış {time}',
'roadtrip.ride.return': 'İade {time}',
'roadtrip.ride.open': 'Rezervasyonu aç',
'roadtrip.ride.undated': '{title} rezervasyonunun tarihi yok, bu yüzden sürüş onu hesaba katmıyor.',
'roadtrip.ride.undated':
'{title} rezervasyonu bu yolculuğun hiçbir gününde değil, bu yüzden sürüş onu hesaba katmıyor.',
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
};
export default roadtrip;
+8 -2
View File
@@ -224,9 +224,15 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.otherEngine': 'Час порахував маршрутизатор обходів, а не основний.',
'roadtrip.alt.notHeld': 'Маршрутизатор подорожі не поведе цим шляхом, тому його не збережено.',
'roadtrip.alt.ferryNotHeld':
'Цей шлях проходить поромом. Додайте пором як бронювання транспорту, і маршрут піде ним.',
'Цей шлях проходить поромом. Додайте пором як бронювання транспорту, і маршрут піде ним. Якщо він прибуває наступного дня, перенесіть зупинки на іншому березі на цей день.',
'roadtrip.alt.legChanged': 'Відрізок змінився під час перевірки, тому нічого не збережено.',
'roadtrip.alt.offline': 'Інші шляхи зберігаються лише онлайн. Під’єднайтеся знову, щоб вибрати цей.',
'roadtrip.alt.otherEngineStandard': 'Час порахував основний маршрутизатор, а не той, яким планується цей відрізок.',
'roadtrip.alt.avoidNotHeld':
'Подорож поїде цим шляхом, лише якщо в її налаштуваннях у розділі «{setting}» позначено «{class}».',
'roadtrip.alt.checking': 'Маршрутизатор подорожі перевіряє цей шлях…',
'roadtrip.alt.standIn':
'Маршрутизатор обходів не відповів для цього відрізка, тому його намалював основний маршрутизатор. Маршрутизатор обходів запитується знову.',
'roadtrip.day': 'День {number}',
'roadtrip.quietDay.empty': 'Зупинок ще немає — перетягніть сюди',
'roadtrip.quietDay.one': 'Поки лише {name} — перетягніть сюди зупинку',
@@ -280,7 +286,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Отримання {time}',
'roadtrip.ride.return': 'Повернення {time}',
'roadtrip.ride.open': 'Відкрити бронювання',
'roadtrip.ride.undated': 'Бронювання {title} не має дати, тому маршрут його не враховує.',
'roadtrip.ride.undated': 'Бронювання {title} не припадає на жоден день цієї подорожі, тому маршрут його не враховує.',
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
};
export default roadtrip;
+9 -2
View File
@@ -222,9 +222,16 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.quicker': 'nhanh hơn {time}',
'roadtrip.alt.otherEngine': 'Giờ do máy chủ định tuyến tránh đường tính, không phải máy chính.',
'roadtrip.alt.notHeld': 'Máy chủ định tuyến của chuyến đi không đi theo đường này, nên đường này chưa được lưu.',
'roadtrip.alt.ferryNotHeld': 'Đường này đi qua phà. Hãy thêm phà làm đặt chỗ phương tiện và lộ trình sẽ đi theo nó.',
'roadtrip.alt.ferryNotHeld':
'Đường này đi qua phà. Hãy thêm phà làm đặt chỗ phương tiện và lộ trình sẽ đi theo nó. Nếu phà cập bến vào ngày hôm sau, hãy đặt các điểm dừng ở bờ bên kia vào ngày đó.',
'roadtrip.alt.legChanged': 'Chặng này đã thay đổi trong lúc kiểm tra, nên chưa có gì được lưu.',
'roadtrip.alt.offline': 'Các đường khác chỉ được lưu khi trực tuyến. Hãy kết nối lại để chọn đường này.',
'roadtrip.alt.otherEngineStandard': 'Giờ do máy chủ định tuyến chính tính, không phải máy dùng để lập chặng này.',
'roadtrip.alt.avoidNotHeld':
'Chuyến đi chỉ đi đường này khi “{class}” được chọn trong “{setting}” ở phần cài đặt của nó.',
'roadtrip.alt.checking': 'Đang kiểm tra đường này với máy chủ định tuyến của chuyến đi…',
'roadtrip.alt.standIn':
'Máy chủ định tuyến tránh đường không phản hồi cho chặng này, nên máy chính đã vẽ nó. Đang hỏi lại máy chủ định tuyến tránh đường.',
'roadtrip.day': 'Ngày {number}',
'roadtrip.quietDay.empty': 'Chưa có điểm dừng — thả vào đây',
'roadtrip.quietDay.one': 'Hiện chỉ có {name} — thả một điểm dừng vào đây',
@@ -278,7 +285,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': 'Nhận xe {time}',
'roadtrip.ride.return': 'Trả xe {time}',
'roadtrip.ride.open': 'Mở đặt chỗ',
'roadtrip.ride.undated': 'Đặt chỗ {title} chưa có ngày, nên hành trình không tính đến nó.',
'roadtrip.ride.undated': 'Đặt chỗ {title} không thuộc ngày nào của chuyến đi này, nên hành trình không tính đến nó.',
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
};
export default roadtrip;
+7 -2
View File
@@ -209,9 +209,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.quicker': '快 {time}',
'roadtrip.alt.otherEngine': '駕駛時間由避開專用的路徑服務計算,無法與其他項比較。',
'roadtrip.alt.notHeld': '公路旅行的路徑服務不會走這條路,因此未儲存。',
'roadtrip.alt.ferryNotHeld': '這條路需要搭渡輪。將渡輪新增為交通預訂,路線就會隨之調整。',
'roadtrip.alt.ferryNotHeld':
'這條路需要搭渡輪。將渡輪新增為交通預訂,路線就會隨之調整。如果渡輪隔天才抵達,請把對岸的停靠點放到那一天。',
'roadtrip.alt.legChanged': '檢查期間這一段有所變更,因此沒有儲存任何內容。',
'roadtrip.alt.offline': '其他走法只能在連線時儲存。重新連線後即可選擇這條。',
'roadtrip.alt.otherEngineStandard': '駕駛時間由主要路徑服務計算,而非規劃這一段所用的服務。',
'roadtrip.alt.avoidNotHeld': '只有在設定的「{setting}」中勾選「{class}」,公路旅行才會走這條路。',
'roadtrip.alt.checking': '正在用公路旅行的路徑服務檢查這條路…',
'roadtrip.alt.standIn': '避開專用的路徑服務沒有回應這一段,因此由主要路徑服務繪製。正在重新詢問避開專用的路徑服務。',
'roadtrip.day': '第 {number} 天',
'roadtrip.quietDay.empty': '尚無停靠點 — 拖到這裡',
'roadtrip.quietDay.one': '目前只有 {name} — 把停靠點拖到這裡',
@@ -265,7 +270,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': '取車 {time}',
'roadtrip.ride.return': '還車 {time}',
'roadtrip.ride.open': '開啟預訂',
'roadtrip.ride.undated': '{title} 沒有日期,因此行程不會用到它。',
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不會用到它。',
'roadtrip.bookings.loose': '當天的其他預訂',
};
export default roadtrip;
+7 -2
View File
@@ -210,9 +210,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.alt.quicker': '快 {time}',
'roadtrip.alt.otherEngine': '用时由避开引擎计算,而非主引擎。',
'roadtrip.alt.notHeld': '公路旅行的路径服务不会走这条路,因此未保存。',
'roadtrip.alt.ferryNotHeld': '这条路需要乘渡轮。把渡轮添加为交通预订,路线就会随之调整。',
'roadtrip.alt.ferryNotHeld':
'这条路需要乘渡轮。把渡轮添加为交通预订,路线就会随之调整。如果渡轮次日才到,请把对岸的停靠点放到那一天。',
'roadtrip.alt.legChanged': '检查期间这一段发生了变化,因此没有保存任何内容。',
'roadtrip.alt.offline': '其他走法只能在联网时保存。重新连接后即可选择这条。',
'roadtrip.alt.otherEngineStandard': '用时由主引擎计算,而非规划这一段所用的引擎。',
'roadtrip.alt.avoidNotHeld': '只有在设置的“{setting}”中勾选“{class}”,公路旅行才会走这条路。',
'roadtrip.alt.checking': '正在用公路旅行的路径服务检查这条路…',
'roadtrip.alt.standIn': '避开引擎没有回应这一段,因此由主引擎绘制。正在重新询问避开引擎。',
'roadtrip.day': '第 {number} 天',
'roadtrip.quietDay.empty': '暂无停靠点 — 拖到这里',
'roadtrip.quietDay.one': '目前只有 {name} — 把停靠点拖到这里',
@@ -266,7 +271,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.ride.pickup': '取车 {time}',
'roadtrip.ride.return': '还车 {time}',
'roadtrip.ride.open': '打开预订',
'roadtrip.ride.undated': '{title} 没有日期,因此行程不会用到它。',
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不会用到它。',
'roadtrip.bookings.loose': '当天的其他预订',
};
export default roadtrip;
+42 -2
View File
@@ -1,5 +1,5 @@
/**
* ROADTRIP-CARRIERS-001..027: a booking the traveller rides becomes a seam in the drive,
* ROADTRIP-CARRIERS-001..029: a booking the traveller rides becomes a seam in the drive,
* and a hire car puts its desks on it.
*
* The road ends at the terminal the ride leaves from and starts again at the one it
@@ -8,7 +8,8 @@
* nothing about a terminal reads as a stored stop, and that a hire car's pick-up and
* return are points the road runs through rather than a seam in it. From 022 on: a ride
* on no day is named rather than dropped, and a ride within one day is seated where it
* adds the least road, as far as the clock leaves a choice (#2461).
* adds the least road, as far as the clock leaves a choice (#2461). From 028 on: a ride
* that lands on a later day is not seated there by the slot seeded on the day it left.
*/
import { assembleRoadtrip } from './assemble';
import {
@@ -775,3 +776,42 @@ describe('a ride within one day, seated by where it goes (#2461)', () => {
expect(rideSeatAfter(close, muc)).toBeNull();
});
});
describe('a ride that lands on a later day (#2461)', () => {
const overnight = (over: Partial<CarrierBooking> = {}): CarrierBooking =>
ferry({ end_day_id: 2, reservation_end_time: '2026-10-07T09:00', ...over });
const newcastleOnDay2 = [stop({ ownerIndex: 0, ownerDayId: 2, name: 'Newcastle', ...NEWCASTLE })];
it('ROADTRIP-CARRIERS-028: the booking’s own slot seats its departure only, and the arrival opens the day it lands on', () => {
// Seeded on the evening of the crossing behind both of its stops, the slot sat behind
// the morning's stop across the water too, and the drive ran from it back to the pier.
const seeded = carrierSeam(overnight({ day_plan_position: 1.5 }))!;
expect(seeded.departure.position).toBe(1.5);
expect(seeded.arrival!.position).toBeNull();
expect(order(seatCarrierStops(2, newcastleOnDay2, [0], [seeded]))).toEqual(['arrival', 'Newcastle']);
// A slot somebody gave it on the day it lands still wins, and so does a leg's own.
const dragged = carrierSeam(overnight({ day_plan_position: 1.5, day_positions: { '2': 0.5 } }))!;
expect(dragged.arrival!.position).toBe(0.5);
expect(order(seatCarrierStops(2, newcastleOnDay2, [0], [dragged]))).toEqual(['Newcastle', 'arrival']);
// On the day it leaves, and for a ride within one day, nothing changes.
expect(carrierSeam(ferry({ day_plan_position: 1.5 }))!.arrival!.position).toBe(1.5);
});
it('ROADTRIP-CARRIERS-029: a hire car handed back on a later day keeps reading the booking’s slot', () => {
const car = carrierSeam({
id: 5,
type: 'car',
title: 'Hire car',
day_id: 1,
end_day_id: 2,
reservation_time: '10:00',
reservation_end_time: '10:00',
day_plan_position: 1.5,
endpoints: [
{ role: 'from', sequence: 0, name: 'Desk A', code: null, lat: 50, lng: 10 },
{ role: 'to', sequence: 1, name: 'Desk B', code: null, lat: 51, lng: 10 },
],
})!;
expect(car.arrival!.position).toBe(1.5);
});
});
+13 -1
View File
@@ -225,6 +225,18 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
const bookingPosition = (dayId: number): number | null =>
positionOn(booking.day_positions, dayId) ??
(typeof booking.day_plan_position === 'number' ? booking.day_plan_position : null);
// The booking's own slot (`day_plan_position`) is one number for every day it spans,
// and the day plan seeds it on the day the booking leaves, against that day's stops.
// Read on the day a ride lands it named a seat among stops it was never measured
// against: an overnight ferry seeded behind both stops of its evening sat behind the
// morning's stop across the water, and the drive went from that stop back to the pier
// (#2461). So a ride landing on a later day takes only a slot somebody gave it on that
// day. Without one the clock seats it there, and with nothing timed ahead of it the
// arrival opens the day (`opensTheDay`).
const arrivalPosition = (dayId: number): number | null =>
kind === 'ride' && dayId !== depDayId
? (positionOn(last?.day_positions, dayId) ?? positionOn(booking.day_positions, dayId))
: (positionOn(last?.day_positions, dayId) ?? bookingPosition(dayId));
return {
reservationId: booking.id,
@@ -245,7 +257,7 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
to && arrDayId != null
? {
dayId: arrDayId,
position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId),
position: arrivalPosition(arrDayId),
clock: arrClock,
name: to.name,
code: to.code ?? null,
+45
View File
@@ -16,7 +16,9 @@ import {
scheduleStopOf,
carriedSeam,
reanchorAfterRemove,
reanchorAfterReorder,
reanchorByStopOrder,
seamViaIndex,
} from './roadtripModel';
import { describe, it, expect } from 'vitest';
@@ -1198,3 +1200,46 @@ describe('carriedSeam', () => {
expect(reanchorAfterRemove([via(9, 2)], 0, 3)).toEqual({ vias: [{ id: 9, after_order_index: 1 }], remove: [] });
});
});
describe('seamViaIndex', () => {
const via = (id: number, after: number) => ({ id, after_order_index: after, lat: 53, lng: 10 });
/** The day's stops after `from` was taken out and put back at `to`. */
const moved = (ids: number[], from: number, to: number): number[] => {
const next = ids.filter((_, i) => i !== from);
next.splice(to, 0, ids[from]!);
return next;
};
it('FE-ROADTRIP-MODEL-109: the via behind the last stop stays while that stop is last and goes once it is not', () => {
expect(seamViaIndex(2, [10, 20, 30], [10, 30])).toBe(1);
expect(seamViaIndex(2, [10, 20, 30], [30, 10, 20])).toBeNull();
expect(seamViaIndex(2, [10, 20, 30], [10, 30, 20])).toBeNull();
// A via on a leg of the day is left to the writer.
expect(seamViaIndex(1, [10, 20, 30], [30, 10, 20])).toBeUndefined();
});
it('FE-ROADTRIP-MODEL-110: every writer does the same to it for the same move, and never bends a leg of the day with it', () => {
// The rail's drag (positional) and the list's reorder or the server's sort (by stop)
// used to disagree: one dropped the via, the other put it on the leg its stop leaves by.
for (const count of [2, 3, 4, 5]) {
const ids = Array.from({ length: count }, (_, i) => 10 * (i + 1));
for (let from = 0; from < count; from++) {
for (let to = 0; to < count; to++) {
if (from === to) continue;
const vias = [via(9, count - 1)];
const positional = reanchorAfterReorder(vias, from, to, count);
expect(reanchorByStopOrder(vias, ids, moved(ids, from, to)), `${count}: ${from}->${to}`).toEqual(positional);
const stays = moved(ids, from, to)[count - 1] === ids[count - 1];
expect(positional, `${count}: ${from}->${to}`).toEqual(
stays ? { vias: [], remove: [] } : { vias: [], remove: [9] },
);
}
// Taken out: the same, by position and by stop.
const removed = ids.filter((_, i) => i !== from);
expect(reanchorByStopOrder([via(9, count - 1)], ids, removed)).toEqual(
reanchorAfterRemove([via(9, count - 1)], from, count),
);
}
}
});
});
+55 -21
View File
@@ -545,7 +545,7 @@ const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] };
/**
* Where a via filed behind a day's last stop goes once the day's stops change: from its
* old index `from` to `to`, the index that stop has now. Null when the stop is no longer
* the day's last, and such a via is then read like any other.
* the day's last, and such a via then goes (`seamViaIndex`).
*
* Such a via bends no leg of its own day. It shapes the drive from the day's last stop
* into the next day, on a trip with connected days or a night drive: the planner files a
@@ -556,9 +556,7 @@ const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] };
* stop taken out, or dragged up the day, left its via on a number no stop has, bending
* nothing at all.
*
* The one rule for every writer that renumbers a day, the planner's drags and removals as
* much as the server's sorts and seated nights. `previousIds` and `nextIds` are the day's
* located stops in order, before and after.
* `previousIds` and `nextIds` are the day's located stops in order, before and after.
*/
export function carriedSeam(
previousIds: readonly number[],
@@ -569,7 +567,33 @@ export function carriedSeam(
return from >= 0 && to >= 0 && previousIds[from] === nextIds[to] ? { from, to } : null;
}
/** The stops of a day as their own positions, for asking `carriedSeam` about a positional edit. */
/**
* What becomes of a via pinned behind stop `index` of the old order when that stop was
* the day's last: the index it is carried to while the stop is still last
* (`carriedSeam`), or null when it goes. Undefined for a via on a leg of the day, which
* each writer reads by its own rule.
*
* It goes once its stop is no longer last, whichever way that came about. It was never a
* leg of the day: it lies on the road to tomorrow, and read as the leg its stop leaves by
* now it bent a drive within the day through a point on that road, for everybody on the
* trip. Which of the two happened used to depend on the surface: a stop dragged up the
* rail dropped the via, while the same stop dragged up the list under Days, or given an
* earlier hour, kept it on its new leg.
*
* The one rule for every writer that renumbers a day: the planner's drags and removals
* (`reanchorAfterReorder`, `reanchorAfterRemove`), the list's reorder and the server's
* sorts (`reanchorByStopOrder`), and the nights the server seats (`carryVias`).
*/
export function seamViaIndex(
index: number,
previousIds: readonly number[],
nextIds: readonly number[],
): number | null | undefined {
if (index !== previousIds.length - 1) return undefined;
return carriedSeam(previousIds, nextIds)?.to ?? null;
}
/** The stops of a day as their own positions, for asking `seamViaIndex` about a positional edit. */
const positions = (count: number): number[] => Array.from({ length: count }, (_, i) => i);
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
@@ -606,13 +630,12 @@ export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopC
const before = positions(stopCount);
const after = before.filter((i) => i !== position);
const seam = carriedSeam(before, after);
const last = stopCount - 1;
// A day left with one stop has no leg, and only the drive out of it into the next day
// can keep its points.
if (stopCount <= 2) return collect(vias, (i) => (i === last ? (seam?.to ?? null) : null));
if (stopCount <= 2) return collect(vias, (i) => seamViaIndex(i, before, after) ?? null);
return collect(vias, (i) => {
if (i === last) return seam?.to ?? null;
const seam = seamViaIndex(i, before, after);
if (seam !== undefined) return seam;
if (position === 0) return i === 0 ? null : i - 1;
if (position === stopCount - 1) return i === position - 1 ? null : i;
if (i === position) return position - 1;
@@ -622,12 +645,13 @@ export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopC
export function reanchorByStopOrder(vias: AnchoredVia[], previousIds: number[], nextIds: number[]): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING;
const seam = carriedSeam(previousIds, nextIds);
const moved: ReanchoredVia[] = [];
const remove: number[] = [];
for (const via of vias) {
if (seam && via.after_order_index === seam.from) {
if (seam.to !== seam.from) moved.push({ id: via.id, after_order_index: seam.to });
const seam = seamViaIndex(via.after_order_index, previousIds, nextIds);
if (seam !== undefined) {
if (seam === null) remove.push(via.id);
else if (seam !== via.after_order_index) moved.push({ id: via.id, after_order_index: seam });
continue;
}
const stopId = previousIds[via.after_order_index]!;
@@ -645,30 +669,40 @@ export function reanchorByStopOrder(vias: AnchoredVia[], previousIds: number[],
export function reanchorAfterReorder(vias: AnchoredVia[], from: number, to: number, stopCount: number): Reanchoring {
if (!vias.length || from === to) return EMPTY_REANCHORING;
// Two stops swapped keep their one leg, driven the other way round. The drive into the
// next day left from the stop that is first now, so its points go.
if (stopCount <= 2) {
const seam = vias.filter((v) => v.after_order_index === stopCount - 1).map((v) => v.id);
return seam.length ? { vias: [], remove: seam } : EMPTY_REANCHORING;
// The drive into the next day is settled first, by the one rule for it (`seamViaIndex`):
// it stays behind the last stop while that stop is still last and goes otherwise, be it
// dragged up the day or passed by another. A move keeps the count, so a via that stays
// keeps its number. Everything else is on a leg of the day and is worked out below.
const before = positions(stopCount);
const after = before.filter((i) => i !== from);
after.splice(to, 0, from);
const seamGone: number[] = [];
const legs: AnchoredVia[] = [];
for (const via of vias) {
const seam = seamViaIndex(via.after_order_index, before, after);
if (seam === null) seamGone.push(via.id);
else if (seam === undefined) legs.push(via);
}
const afterRemove = reanchorAfterRemove(vias, from, stopCount);
// Two stops swapped keep their one leg, driven the other way round.
if (stopCount <= 2 || !legs.length) return seamGone.length ? { vias: [], remove: seamGone } : EMPTY_REANCHORING;
const afterRemove = reanchorAfterRemove(legs, from, stopCount);
const dropped = new Set(afterRemove.remove);
const movedTo = new Map(afterRemove.vias.map((v) => [v.id, v.after_order_index] as const));
const shifted: AnchoredVia[] = vias
const shifted: AnchoredVia[] = legs
.filter((v) => !dropped.has(v.id))
.map((v) => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }));
const afterInsert = reanchorAfterInsert(shifted, to, () => true);
const finalIndex = new Map(afterInsert.vias.map((v) => [v.id, v.after_order_index] as const));
const result: ReanchoredVia[] = [];
for (const via of vias) {
for (const via of legs) {
if (dropped.has(via.id)) continue;
const mid = movedTo.get(via.id) ?? via.after_order_index;
const end = finalIndex.get(via.id) ?? mid;
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end });
}
return { vias: result, remove: afterRemove.remove };
return { vias: result, remove: [...afterRemove.remove, ...seamGone] };
}
export function isEmptyReanchoring(r: Reanchoring): boolean {