Compare commits

..

5 Commits

Author SHA1 Message Date
Maurice e79d0996ae Merge branches 'fix/mcp-trip-default-currency' and 'fix/mobile-reservation-detail-slot' into test/dev1-mcp-mobile 2026-09-22 09:59:05 +02:00
Maurice 8936049a81 fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 09:56:13 +02:00
Maurice c0b691b8e5 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 09:56:13 +02:00
Maurice aa64572bf0 fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false; the nested objects authored inline in the tool shapes are strict
for the same reason. A whole Zod schema, the way a plugin's manifest-built
tool arrives, passes through as its author wrote it.
2026-09-22 09:55:45 +02:00
Maurice 23aec0d898 fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 09:55:33 +02:00
589 changed files with 3210 additions and 31444 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.2
version: 4.3.0
description: Minimal Helm chart for TREK app
appVersion: "4.3.2"
appVersion: "4.3.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.2",
"version": "4.3.0",
"private": true,
"type": "module",
"scripts": {
+7 -27
View File
@@ -580,44 +580,24 @@ describe('Version cache-busting', () => {
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-027: an update path that throws is retried on the next launch, harmlessly (#2228)', async () => {
// A handover that never happened must not be recorded as done, or the
// device stays on the old build for good. The retry this buys has to be
// harmless: the purge that once repeated on every launch is gone, and a
// failure neither deletes anything nor reloads into the old worker's shell.
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
// The marker used to be written after the purge and outside its try, so a
// throwing step left it unwritten and the whole destructive path repeated on
// every launch, for good.
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
const getRegistration = vi.fn().mockRejectedValue(new Error('no worker'))
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration, getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn() },
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10', places_provider: 'google' })
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
const first = renderApp('/')
await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(1))
await act(async () => { await Promise.resolve() })
first.unmount()
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
renderApp('/')
await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(2))
await act(async () => { await Promise.resolve() })
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
// Without a reload the session carries on, so the rest of the config applies.
expect(useAuthStore.getState().placesProvider).toBe('google')
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
})
})
+43 -6
View File
@@ -17,7 +17,6 @@ import MobileShell from './mobile/MobileShell'
import MRouteFallback from './mobile/components/MRouteFallback'
import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { reconcileAppVersion } from './utils/versionHandover'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client'
@@ -285,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -302,7 +301,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -310,7 +309,6 @@ export default function App() {
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.places_provider) setPlacesProvider(config.places_provider)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
@@ -320,8 +318,47 @@ export default function App() {
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version)
// A version is a short release tag and nothing else. It arrives over the
// wire and is written to this device's storage, so only a value made of
// the characters a tag may contain is taken, as the match itself. Anything
// else is ignored, which also keeps a malformed value from being compared
// against the stored marker and starting an update on every launch.
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
if (releaseTag) {
const version = releaseTag[0]
const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private
// mode, blocked site data, quota) left the caches deleted, the marker
// unwritten and the reload unreached, and the purge then repeated on
// every single launch, permanently (#2228).
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
if (storedVersion && storedVersion !== version) {
// A newer build is deployed. Ask the service worker to fetch it and
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
// installs the new precache and only then drops the outdated one, so
// there is never a moment without an app shell.
//
// This used to delete EVERY Cache Storage bucket and unregister EVERY
// worker instead. That left the device with no shell and no worker
// until a fresh ~22 MB precache finished (minutes on mobile data),
// and anyone who closed the app or lost signal in that window was
// left with a PWA that could no longer start offline at all. It also
// threw away the map tiles and file blobs the user had deliberately
// downloaded for offline use (#2228).
try {
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
if (reg) {
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
await reg.update()
return
}
} catch { /* fall through to a plain reload */ }
window.location.reload()
return
}
}
}).catch(() => {})
}, [])
-281
View File
@@ -1,281 +0,0 @@
import React from 'react'
import { render, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { resetAllStores } from '../tests/helpers/store'
import App from './App'
/*
* #2458: an installed PWA kept serving the previous UI after a server upgrade.
*
* The launch probe compares the server's version with a marker in localStorage
* and, on a mismatch, asks the service worker to fetch the new build and reloads
* once it has taken over. These tests drive that probe across consecutive
* launches against a fake service-worker container, so a handover that never
* completed can be told apart from one that did.
*
* The fake is installed before App is imported (vi.hoisted), so code that
* notes the controlling worker when its module loads sees the worker that
* served this page, exactly as in the browser.
*/
const sw = vi.hoisted(() => {
class FakeWorker extends EventTarget {
state: string
readonly scriptURL = '/sw.js'
constructor(state: string) {
super()
this.state = state
}
become(state: string) {
this.state = state
this.dispatchEvent(new Event('statechange'))
}
}
class FakeRegistration extends EventTarget {
active: FakeWorker | null = null
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
readonly scope = '/'
update = vi.fn()
}
class FakeContainer extends EventTarget {
controller: FakeWorker | null = null
getRegistration = vi.fn()
// Listeners a page adds die with that page. Those added while modules load
// (before `trackPage` is switched on) stand for module-level code and stay.
trackPage = false
private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = []
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
super.addEventListener(type, listener, options)
if (this.trackPage && listener) this.pageListeners.push([type, listener, options])
}
dropPageListeners() {
for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options)
this.pageListeners = []
}
}
const oldWorker = new FakeWorker('activated')
const registration = new FakeRegistration()
const container = new FakeContainer()
registration.active = oldWorker
container.controller = oldWorker
container.getRegistration.mockImplementation(async () => registration)
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
return { FakeWorker, registration, container, oldWorker }
})
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() }))
// Every import above has been evaluated by now; from here on a listener belongs to a page.
sw.container.trackPage = true
const reload = vi.fn()
let configHits = 0
function serveVersion(version: string, gate?: Promise<void>) {
server.use(
http.get('/api/auth/app-config', async () => {
configHits++
if (gate) await gate
return HttpResponse.json({ version })
}),
)
}
/** The browser's handover: the new worker installs, activates and claims the page. */
function handOver() {
const next = new sw.FakeWorker('activated')
sw.registration.installing = null
sw.registration.active = next
sw.container.controller = next
sw.container.dispatchEvent(new Event('controllerchange'))
}
/** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */
function updateStartsInstall(then: 'activates' | 'fails') {
sw.registration.update.mockImplementation(async () => {
const next = new sw.FakeWorker('installing')
sw.registration.installing = next
setTimeout(() => {
if (then === 'activates') {
next.become('activated')
handOver()
} else {
// Workbox precaching is all-or-nothing: one failed fetch out of ~463
// rejects the install event and the new worker is thrown away.
sw.registration.installing = null
next.become('redundant')
}
}, 0)
return sw.registration
})
}
/** One cold start of the installed app: fresh page, fresh session, same localStorage. */
async function launch() {
sessionStorage.clear()
const hitsBefore = configHits
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(hitsBefore + 1))
// Let the probe, update() and the fake install run to completion.
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
configHits = 0
sw.registration.active = sw.oldWorker
sw.registration.installing = null
sw.registration.waiting = null
sw.registration.update.mockReset()
sw.registration.update.mockResolvedValue(sw.registration)
sw.container.controller = sw.oldWorker
sw.container.dropPageListeners()
sw.container.getRegistration.mockReset()
sw.container.getRegistration.mockImplementation(async () => sw.registration)
reload.mockReset()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })),
http.get('/api/system-notices/active', () => HttpResponse.json([])),
)
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
user: null,
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
})
})
describe('#2458 version handover: behaviour that must survive the fix', () => {
it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
expect(localStorage.getItem('trek_app_version')).toBe('4.3.1')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => {
// A release that changed nothing the precache holds leaves sw.js byte-identical,
// so update() installs nothing. The bundle this page runs is already current.
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
})
it.each([
['a self-built image without the build arg', 'dev'],
['a test instance pinned to an older tag', '4.0.0'],
['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'],
])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => {
// The server reports APP_VERSION, the bundle carries client/package.json. On
// these setups the two never agree, so a check against the bundle's own
// version would see an update on every launch.
serveVersion(serverVersion)
await launch()
await launch()
await launch()
expect(reload).not.toHaveBeenCalled()
expect(sw.registration.update).not.toHaveBeenCalled()
})
})
describe('#2458 version handover: the reported defect', () => {
it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
// Next cold start, network fine this time.
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalled()
})
it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => {
// The old worker still controls the page, so a plain reload is answered from
// its precache: the same old UI, now with the marker saying it is current.
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch()
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('fails')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalledTimes(1)
})
// Kept last: it swaps the controlling worker mid-launch, which code that
// remembers "the controller changed since this page loaded" keeps seeing.
it('FE-COMP-APP-2458-04: a worker that took over before the probe answered still reloads the page', async () => {
// The browser checks sw.js on every navigation by itself. When that check
// installs and activates the new worker before /api/auth/app-config has
// answered, the takeover happens while nobody listens, and update() then
// finds nothing newer. The page keeps running the bundle the old worker served.
localStorage.setItem('trek_app_version', '4.3.0')
let open!: () => void
const gate = new Promise<void>(r => { open = r })
serveVersion('4.3.1', gate)
sessionStorage.clear()
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(1))
act(() => handOver())
open()
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
expect(reload).toHaveBeenCalledTimes(1)
})
})
+1 -6
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-058
// FE-APISURF-001 to FE-APISURF-057
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -572,11 +572,6 @@ describe('client > request payloads', () => {
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
})
it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => {
const rec = await traceOne(() => daysApi.create(1, { dated: true }))
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } })
})
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
+7 -13
View File
@@ -1,7 +1,7 @@
import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { Place } from '../types'
import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId'
import {
@@ -434,17 +434,11 @@ export const tripsApi = {
export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
// `trip` comes along when the new day changed the trip itself: a dated day
// (`dated: true`) moved its end date, an insert at a position grew it by a day.
create: (tripId: number | string, data: DayCreateRequest): Promise<{ day: Day; trip?: Trip }> =>
apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
// Whole-day default route mode (#1281); per-segment leg modes override it.
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
// Answers with the trip in list shape: its day count changed, and its end
// date when the day took the last date along.
delete: (tripId: number | string, dayId: number | string): Promise<{ success: boolean; trip?: Trip }> =>
apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
}
@@ -1222,11 +1216,11 @@ export const mapsApi = {
* caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order,
* and a plugin's row carries its own `source` for a caller that wants to mark it.
*
*/
/**
* `provider: 'google'` sends this one search to Google alone, the "search Google
* instead" link under a list the index answered with the wrong place. The server
* ignores it unless Google holds the keyed slot: without a Google key, or with
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
* instead" link under a list the index answered with the wrong place. The
* server ignores it on an instance without a Google key.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
@@ -1,4 +1,4 @@
// FE-W4BGT-001 to FE-W4BGT-024
// FE-W4BGT-001 to FE-W4BGT-022
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, act, waitFor } from '@testing-library/react'
import { render, fireEvent } from '../../../tests/helpers/render'
@@ -276,31 +276,3 @@ describe('BackgroundTasksWidget — AI retry', () => {
expect(await screen.findByText('No model configured')).toBeInTheDocument()
})
})
/**
* The reporter's phone showed a green tick beside "No reservations could be
* extracted" and two warnings (#2477). A parse that found nothing is not a
* success, so it wears the warning mark; the tick stays for a parse that did.
*/
describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => {
it('FE-W4BGT-023: a job that found nothing shows the warning mark, not the success tick', () => {
useBackgroundTasksStore.setState({
tasks: [task({ items: [], warnings: ['Incomplete LodgingReservation in booking.pdf[0] (no reservationFor)'] })],
})
const { baseElement } = render(<BackgroundTasksWidget />)
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toHaveAttribute('stroke', 'var(--warning)')
expect(baseElement.querySelector('svg.lucide-check-circle2')).toBeNull()
expect(baseElement.querySelector('.animate-spin')).toBeNull()
})
it('FE-W4BGT-024: a job with items keeps the success tick, even with warnings', () => {
useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never, warnings: ['could not locate "X"'] })] })
const { baseElement } = render(<BackgroundTasksWidget />)
// The theme's own success colour, so it follows light and dark.
expect(baseElement.querySelector('svg.lucide-check-circle2')).toHaveAttribute('stroke', 'var(--success)')
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull()
})
})
@@ -1,7 +1,7 @@
import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X } from 'lucide-react'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi, healthApi } from '../../api/client'
@@ -141,12 +141,8 @@ export default function BackgroundTasksWidget() {
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && task.items.length > 0 && <CheckCircle2 size={16} color="var(--success)" />}
{/* A parse that found nothing is not a success, whatever the job
status says: the same card used to show a green tick next to
"no reservations could be extracted" (#2477). */}
{task.status === 'done' && task.items?.length === 0 && <AlertTriangle size={16} color="var(--warning)" />}
{task.status === 'error' && <AlertCircle size={16} color="var(--danger)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -1,4 +1,4 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-013
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -10,9 +10,7 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -162,67 +160,6 @@ describe('CollabLinks', () => {
expect(screen.getByText('ferries.example')).toBeInTheDocument();
});
it('FE-COMP-LINKS-011: deleting asks first, and the DELETE goes out only once confirmed', async () => {
// The link goes for every member, and on the phone the button is a thumb's
// width from pin: the notes panel guards the same way.
const user = userEvent.setup();
let deleted = false;
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i }));
expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument();
expect(deleted).toBe(false);
expect(screen.getByText('Ferry timetable')).toBeInTheDocument();
// The confirm button of the question itself, found by its role rather than a colour class.
const question = screen.getByText(/delete link\?|collab\.links\.confirmDeleteTitle/i).closest('.trek-modal-enter') as HTMLElement;
await user.click(within(question).getByRole('button', { name: /^(delete|common\.delete)$/i }));
await waitFor(() => expect(deleted).toBe(true));
await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument());
});
it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => {
// The icon remembers a favicon that failed, and an edit keeps the chip's id,
// so the corrected address kept the generic glyph until the panel remounted.
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })),
http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })),
);
render(<CollabLinks tripId={1} />);
const chip = await screen.findByRole('link', { name: /ferry timetable/i });
fireEvent.error(chip.querySelector('img') as HTMLImageElement);
await waitFor(() => expect(chip.querySelector('img')).toBeNull());
await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i }));
const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i);
await user.clear(url);
await user.type(url, 'https://new.example/page');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img'))
.toHaveAttribute('src', 'https://new.example/favicon.ico'));
});
it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => {
// jsdom lays nothing out, so the rule is the assertion. The phone Collab tab
// mounts this chip; 22px buttons a pixel apart put delete one slip from pin.
const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions');
expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1);
const touch = css.slice(at, css.indexOf('\n}', at));
expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/);
const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? '';
for (const side of ['width', 'height']) {
expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24);
}
// The height comes back through the margin, so the chip itself stays 36px.
expect(action).toMatch(/margin:\s*-4px 0/);
});
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
// collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
+2 -16
View File
@@ -6,7 +6,6 @@ import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast'
@@ -112,7 +111,6 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
// Both are new objects on every render, so depending on them would reload the
// list after each keystroke and drop a link that was just added back out of it.
@@ -232,9 +230,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{links.map(link => (
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
{/* Keyed on the address: the icon remembers a favicon that failed, and
an edit keeps the chip's id, so a corrected address got the glyph. */}
<LinkIcon key={link.url} url={link.url} />
<LinkIcon url={link.url} />
<span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
@@ -248,7 +244,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
@@ -262,16 +258,6 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{/* Create and edit modals */}
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
{/* The delete is shared: the link goes for every member, and on the phone the
button is a thumb's width from pin. Same guard as the notes panel. */}
<ConfirmDialog
isOpen={pendingDeleteId !== null}
onClose={() => setPendingDeleteId(null)}
onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)}
title={t('collab.links.confirmDeleteTitle')}
message={t('collab.links.confirmDeleteBody')}
/>
</div>
)
}
@@ -201,9 +201,7 @@ describe('CollabNotes', () => {
// Deleting now asks for confirmation first — the note stays until confirmed.
expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument();
// The confirm button of the question itself, found by its role rather than a colour class.
const question = screen.getByText('Delete note?').closest('.trek-modal-enter') as HTMLElement;
await user.click(within(question).getByRole('button', { name: 'Delete' }));
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
});
@@ -1,4 +1,4 @@
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-023
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-022
/**
* The document-sync dialog shell (#2391).
@@ -49,27 +49,6 @@ vi.mock('../../../api/client', async (importOriginal) => {
}
})
/** Set by the one test that needs a render slower than React's time slice. */
let slowCard = false
vi.mock('./DocSyncBinding', async (importOriginal) => {
const actual = await importOriginal<typeof import('./DocSyncBinding')>()
const Card = actual.default
return {
...actual,
default: function SlowableBinding(props: Parameters<typeof Card>[0]) {
// Busy for longer than the scheduler's 5 ms slice, so the commit that
// shows the card leaves its effects for a later task, as a loaded CI
// runner does.
if (slowCard) {
const end = performance.now() + 20
while (performance.now() < end) { /* busy */ }
}
return <Card {...props} />
},
}
})
import DocSyncPanel from './DocSyncPanel'
import type { DocSyncLink, DocSyncProvider } from './useDocSync'
@@ -163,7 +142,6 @@ const confirmUnlink = () => {
beforeEach(() => {
vi.clearAllMocks()
slowCard = false
serverHas({})
deleteLink.mockResolvedValue({})
updateLink.mockResolvedValue({})
@@ -323,39 +301,6 @@ describe('DocSyncPanel: the store list', () => {
expect(second).toHaveAttribute('aria-current', 'true')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current')
})
it('FE-DOCSYNC-PANEL-023: a store picked before the list-following effect has run stays picked', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
slowCard = true
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
// Outside act, as during the wait inside findBy*: React commits the card on
// its own scheduler, and the click lands the moment the card is in the DOM,
// before the effect that follows the list has had its turn.
const env = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
const previous = env.IS_REACT_ACT_ENVIRONMENT
env.IS_REACT_ACT_ENVIRONMENT = false
try {
await new Promise<void>(resolve => {
const observer = new MutationObserver(() => {
if (!document.querySelector('article')) return
observer.disconnect()
fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ }))
resolve()
})
observer.observe(document.body, { childList: true, subtree: true })
})
} finally {
env.IS_REACT_ACT_ENVIRONMENT = previous
}
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument()
})
})
describe('DocSyncPanel: a member rather than the owner', () => {
@@ -54,12 +54,11 @@ export default function DocSyncPanel({
const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined
// Follow the list: a freshly bound store should be the one on screen, and a
// removed one must not leave the detail column pointing at nothing. The pick
// is read when the update runs, not from this render: a store clicked before
// the effect got its turn was otherwise switched straight back.
// removed one must not leave the detail column pointing at nothing.
useEffect(() => {
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
}, [sync.links])
if (sync.links.length === 0) setSelected(null)
else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id)
}, [sync.links, selected])
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
@@ -1,4 +1,4 @@
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -1045,30 +1045,4 @@ describe('EntryEditor', () => {
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => {
const user = userEvent.setup()
const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => {
// The /original of a video is the video file, which an <img> cannot draw.
const user = userEvent.setup()
const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' }
const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
for (const id of [100, 200]) {
const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement
fireEvent.error(img)
expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`)
}
})
})
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { useEffect, useMemo, useState, useRef } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
@@ -21,27 +21,6 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A photo whose thumbnail is missing can still be drawn from its original. A
// clip's original is the video file, which no <img> can show, so a clip gets
// no fallback (#2341).
function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) {
if (p.media_type === 'video') return undefined
return (e: SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
if (!img.src.includes('/original')) img.src = photoUrl(p, 'original')
}
}
// No poster to show and falling back to /original would hand an <img> the clip
// itself, so this tile stays a play badge.
function ClipTile() {
return (
<div className="absolute inset-0 bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
)
}
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
entry: JourneyEntry
journeyId: number
@@ -455,11 +434,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
>
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
)}
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -553,9 +528,13 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
{posterlessVideo(p) ? (
<ClipTile />
// No poster to show and falling back to /original would hand
// an <img> the clip itself, so this tile stays a play badge.
<div className="w-full h-full bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
)}
{idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-010
// FE-JRN-COVER-001 to FE-JRN-COVER-008
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -112,23 +112,4 @@ describe('JourneyEntryCover', () => {
expect(onClick).toHaveBeenCalledOnce()
})
it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => {
// Its thumbnail route 404s on purpose, so an <img> would have been a broken face
// across the whole card.
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-map-pin')).toBeNull()
})
it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => {
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
})
@@ -1,7 +1,6 @@
import { MapPin, Play, Plus } from 'lucide-react'
import { MapPin, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore'
@@ -26,13 +25,6 @@ import type { JourneyEntry } from '../../store/journeyStore'
* which needs no tokens at all.
*/
/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */
export interface CoverPhoto extends CardPhoto {
media_type?: string | null
provider?: string | null
thumbnail_path?: string | null
}
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
export interface CoverEntry {
id: number
@@ -47,7 +39,7 @@ export interface CoverEntry {
entry_time?: string | null
mood?: string | null
weather?: string | null
photos?: CoverPhoto[]
photos?: CardPhoto[]
}
interface Props {
@@ -77,12 +69,8 @@ export default function JourneyEntryCover({
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
const first = entry.photos?.[0]
const photoId = cardPhotoId(first)
// A clip without a poster has no picture to fill the card with; its thumbnail
// route 404s and an <img> pointed at it would draw the broken glyph (#2341).
const posterless = first != null && posterlessVideo(first)
const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const photoId = cardPhotoId(entry.photos?.[0])
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const title = cardTitle(entry, t)
const place = cardPlace(entry)
@@ -124,9 +112,7 @@ export default function JourneyEntryCover({
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
) : (
<span className="absolute inset-0 flex items-center justify-center">
{posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
</span>
)}
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -234,25 +234,4 @@ describe('MobileEntryView', () => {
expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
expect(screen.queryByText('Sunny')).not.toBeInTheDocument()
})
it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => {
// Its original is the video file, which an <img> cannot show, so the hero used
// to be a broken image for every clip, poster or not.
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => {
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
// The second photo's strip thumbnail is the only image left.
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
})
@@ -7,7 +7,6 @@ import {
import JournalBody from './JournalBody'
import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// Labels are translation keys, like the desktop tables in
@@ -106,18 +105,12 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, 0)}
>
{/* A clip is drawn from its poster, its original being the video file.
Without a poster the black ground and the play badge are the hero (#2341). */}
{posterlessVideo(photos[0]) ? (
<span className="block aspect-video w-full max-h-[50vh]" />
) : (
<img
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -147,14 +140,12 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, i)}
>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -3,18 +3,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const plyrCtor = vi.fn()
vi.mock('plyr', () => ({
default: class {
el: HTMLElement
original: Node
constructor(el: HTMLElement, options: Record<string, unknown>) {
plyrCtor(el, options)
this.el = el
this.original = el.cloneNode(true)
}
// What the real destroy() does to the document: the element as it was at
// construction goes back in, whatever React wrote to it since.
destroy() {
this.el.replaceWith(this.original)
}
destroy = vi.fn()
},
}))
vi.mock('plyr/dist/plyr.css', () => ({}))
@@ -39,7 +31,7 @@ describe('VideoPlayer', () => {
it('resets the element to an empty source on teardown rather than a clip on the CDN', () => {
// media-src refuses cdn.plyr.io too; an empty source aborts the stream just the
// same, and the element is out of the document by then.
// same, and the element is unmounted in the same tick.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
const [, options] = plyrCtor.mock.calls[0]
@@ -53,19 +45,4 @@ describe('VideoPlayer', () => {
expect(el).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail')
})
it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => {
// Rebuilding Plyr on the same <video> ran after React had already pointed it at
// the next clip, and destroy() then swapped the first clip back in. The file
// lightboxes do not key the player themselves, so this has to hold here.
const { container, rerender } = render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
rerender(<VideoPlayer src="/api/photos/8/original" autoPlay={false} />)
expect(plyrCtor).toHaveBeenCalledTimes(2)
const [first] = plyrCtor.mock.calls[0]
const [second] = plyrCtor.mock.calls[1]
expect(second).not.toBe(first)
expect(second).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('src', '/api/photos/8/original')
})
})
+14 -20
View File
@@ -7,30 +7,24 @@ import 'plyr/dist/plyr.css'
// host and would fetch a data URL over XHR, which connect-src refuses as well.
import plyrSprite from 'plyr/dist/plyr.svg?no-inline'
interface Props {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}
/**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
* <video>. Local videos stream with HTTP Range (seeking works out of the box) and
* the source carries the correct video MIME from the server. The Plyr instance is
* created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback.
*
* A new source is a new element: Plyr's destroy() puts a clone of the element as
* it was at construction back into the document, so rebuilding the player on a
* <video> React had already pointed at the next clip left the first clip on
* screen under the next one's name.
*/
export default function VideoPlayer(props: Props): React.ReactElement {
return <PlyrVideo key={props.src} {...props} />
}
function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
@@ -54,8 +48,8 @@ function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactE
iconUrl: plyrSprite,
// On teardown Plyr points the element at a blank clip to abort the stream,
// by default one on cdn.plyr.io, which media-src refuses. An empty source
// aborts the stream just the same, and the element is out of the document
// by then, so the error it would otherwise report has nowhere to show.
// aborts the stream just the same, and the element is unmounted in the same
// tick, so the error it would otherwise report has nowhere to show.
blankVideo: '',
})
@@ -66,7 +60,7 @@ function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactE
/* already torn down */
}
}
}, [autoPlay])
}, [src, autoPlay])
return (
<div
@@ -599,32 +599,6 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-082: a booking switched on for another day keeps drawing unless the map is held to the day', () => {
// The desktop passes no scope, so a booking switched on stays drawn on every day.
// Only a caller that asks for it drops the booking from a day it does not run on.
const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
const reservation = buildReservation({
id: 44,
type: 'flight',
day_id: 10,
end_day_id: 10,
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
})
const reservations = [reservation]
const ids = [44]
const { rerender } = render(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} />)
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} scopeConnectionsToDay />)
expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={10} scopeConnectionsToDay />)
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-073: a category icon that throws leaves the marker circle intact', () => {
// Icon components come from a lookup table a plugin can extend; one that
// throws must not take the whole map down, only its own glyph.
@@ -946,14 +920,6 @@ describe('MapView selection panning (#921)', () => {
expect(mapMock.panTo).toHaveBeenCalledWith([48, 2], { animate: true })
})
it('FE-COMP-MAPVIEW-083: pans to a selected stay whose pin a filter keeps off the map', () => {
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
const stay = buildMapPlace({ id: 11, lat: 50, lng: 4 })
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
rerender(<MapView places={places} selectedPlaceId={11} selectedPlace={stay} />)
expect(mapMock.panTo).toHaveBeenCalledWith([50, 4], { animate: true })
})
it('FE-COMP-MAPVIEW-053: a selected place without coordinates is not panned to', () => {
const places = [buildMapPlace({ id: 10, lat: null, lng: null })]
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
@@ -1064,26 +1030,6 @@ describe('MapView live location', () => {
expect(geoMock.cycleMode).toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
geoMock.mode = 'off'
render(<MapView />)
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
// landscape crosses the 768px cut without remounting the map
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
})
it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
const desktop = render(<MapView />)
// The desktop credit is Leaflet's own, in its own container; this component adds none.
+7 -18
View File
@@ -330,11 +330,10 @@ interface SelectionControllerProps {
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
selectedPlace: Place | null
paddingOpts: L.FitBoundsOptions
}
function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const prev = useRef(null)
@@ -344,7 +343,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
const selected = places.find(p => p.id === selectedPlaceId)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
@@ -358,7 +357,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map])
}, [selectedPlaceId, places, map])
return null
}
@@ -548,13 +547,11 @@ import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } f
import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
// Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
@@ -680,8 +677,6 @@ export const MapView = memo(function MapView({
routeColors = null,
routeSegments = [],
selectedPlaceId = null,
// The selected place itself, for when no pin on this map stands for it.
selectedPlace = null,
hoverDisabled = false,
onMarkerClick,
onMapClick,
@@ -703,9 +698,6 @@ export const MapView = memo(function MapView({
showTransitRoutes = true,
days = [] as Day[],
selectedDayId = null,
// Whether a booking switched on by hand also has to run on the selected day to be
// drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
scopeConnectionsToDay = false,
onReservationClick,
pois = [] as Poi[],
onPoiClick,
@@ -769,8 +761,8 @@ export const MapView = memo(function MapView({
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
@@ -1041,10 +1033,7 @@ export const MapView = memo(function MapView({
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
// The width is followed rather than read once: a phone turned sideways and
// back crosses the breakpoint twice, and the button used to stay with
// whatever the map saw when it mounted.
const isMobile = useIsPhone()
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
@@ -1134,7 +1123,7 @@ export const MapView = memo(function MapView({
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
@@ -1379,33 +1379,6 @@ describe('MapViewGL', () => {
expect(onReservationClick).toHaveBeenCalledWith(77)
})
it('FE-COMP-MAPVIEWGL-087: a booking switched on for another day reaches the overlay unless the map is held to the day', async () => {
loadOnAttach()
const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
const flight = {
id: 44, trip_id: 1, type: 'flight', title: 'A → B', status: 'confirmed', day_id: 10, end_day_id: 10,
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
}
const reservations = [flight]
const ids = [44]
const drawn = () => (reservationOverlay.update.mock.lastCall?.[0] as { id: number }[]).map(r => r.id)
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} />,
)
await act(async () => {})
expect(drawn()).toEqual([44])
rerender(
<MapViewGL places={[]} fitKey={1} reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} scopeConnectionsToDay />,
)
await act(async () => {})
expect(drawn()).toEqual([])
})
it('FE-COMP-MAPVIEWGL-042: explore POIs get their own pin, hover popup and click callback', async () => {
loadOnAttach()
const basePoi: Poi = {
+6 -20
View File
@@ -22,7 +22,6 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
@@ -42,7 +41,6 @@ import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -148,8 +146,6 @@ interface Props {
routeColors?: ({ line: string; casing: string } | undefined)[] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
/** The selected place itself, for when no pin on this map stands for it. */
selectedPlace?: Place | null
onMarkerClick?: (id: number) => void
hoverDisabled?: boolean
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
@@ -167,11 +163,6 @@ interface Props {
showTransitRoutes?: boolean
days?: Day[]
selectedDayId?: number | null
/**
* Whether a booking switched on by hand also has to run on the selected day to be
* drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
*/
scopeConnectionsToDay?: boolean
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
@@ -679,7 +670,6 @@ export function MapViewGL({
routeColors = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
selectedPlace = null,
hoverDisabled = false,
onMarkerClick,
onMapClick,
@@ -704,7 +694,6 @@ export function MapViewGL({
showTransitRoutes = true,
days = NO_DAYS,
selectedDayId = null,
scopeConnectionsToDay = false,
showReservationStats = false,
onReservationClick,
pois = NO_POIS,
@@ -1170,12 +1159,6 @@ export function MapViewGL({
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy), so
// the location button would be misleading; the phone, where real GPS lives,
// keeps it. Read here with the other hooks rather than beside the button:
// the tokenless branch below returns early, and a hook after it runs on some
// renders and not others.
const isMobile = useIsPhone()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick
@@ -2196,8 +2179,8 @@ export function MapViewGL({
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
@@ -2312,7 +2295,7 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !selectedPlaceId) return
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
if (!target?.lat || !target?.lng) return
try {
map.flyTo({
@@ -2399,6 +2382,9 @@ export function MapViewGL({
)
}
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
@@ -13,10 +13,6 @@ import {
parsePluginProfile,
withHotelBookends,
calculateAlternatives,
avoidedClasses,
routeEngineFor,
sameRoad,
furthestFrom,
} from './RouteCalculator'
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
@@ -1053,196 +1049,3 @@ describe('turning round at a stop', () => {
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
})
// ── a trip that avoids something ──────────────────────────────────────────────
/**
* The rail drives a trip with avoided classes through Valhalla, and everything that talks
* about one of its legs has to use the same engine. The offers used to come from OSRM with
* nothing avoided, so the picker described a different drive from the rail's; and an OSRM
* stand-in for a Valhalla that did not answer was filed as the avoided road for the rest
* of the session, without a word on the rail.
*/
describe('a trip that avoids something', () => {
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
/** Polyline6, the way Valhalla sends its shapes. */
const encode6 = (points: [number, number][]): string => {
const part = (value: number): string => {
let n = value < 0 ? ~(value << 1) : value << 1
let out = ''
while (n >= 0x20) { out += String.fromCharCode((0x20 | (n & 0x1f)) + 63); n >>= 5 }
return out + String.fromCharCode(n + 63)
}
let lat = 0
let lng = 0
return points.map(([pLat, pLng]) => {
const a = Math.round(pLat * 1e6)
const b = Math.round(pLng * 1e6)
const piece = part(a - lat) + part(b - lng)
lat = a
lng = b
return piece
}).join('')
}
const trip = (points: [number, number][], summary: Record<string, unknown> = {}) => ({
legs: [{ shape: encode6(points) }],
summary: { length: 100, time: 4000, has_toll: false, has_highway: false, has_ferry: false, ...summary },
})
const NORTH: [number, number][] = [[61, 10], [61.5, 11], [61, 12]]
const SOUTH: [number, number][] = [[61, 10], [60.5, 11], [61, 12]]
const DIRECT: [number, number][] = [[61, 10], [61, 11], [61, 12]]
const from = { lat: 61, lng: 10 }
const to = { lat: 61, lng: 12 }
it('FE-COMP-ROUTECALCULATOR-059: the ways come from Valhalla with the trip classes, and OSRM is never asked', async () => {
let osrm = 0
const bodies: Array<Record<string, unknown>> = []
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
http.post(VALHALLA, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({
trip: trip(DIRECT, { time: 3600 }),
alternates: [{ trip: trip(NORTH, { time: 4200, has_ferry: true }) }, { trip: trip(SOUTH, { time: 4800 }) }],
})
}),
)
const routes = await calculateAlternatives(from, to, 'driving', { avoid: ['toll'] })
expect(osrm).toBe(0)
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ alternates: 2, costing_options: { auto: { use_tolls: 0 } } })
expect(bodies[0].locations).toHaveLength(2)
expect(routes.map(r => r.engine)).toEqual(['valhalla', 'valhalla', 'valhalla'])
expect(routes.map(r => r.duration)).toEqual([3600, 4200, 4800])
expect(routes.map(r => r.hasFerry)).toEqual([false, true, false])
// Pinnable like any other offer: measured against the preferred one.
expect(routes[0].divergence).toBeNull()
expect(routes[1].divergence).toEqual({ lat: 61.5, lng: 11 })
})
it('FE-COMP-ROUTECALCULATOR-060: a Valhalla with nothing to say is the router not answering, not the OSRM roads', async () => {
let osrm = 0
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
http.post(VALHALLA, () => new HttpResponse(null, { status: 503 })),
)
await expect(calculateAlternatives({ lat: 62, lng: 10 }, { lat: 62, lng: 12 }, 'driving', { avoid: ['ferry'] })).rejects.toThrow()
expect(osrm).toBe(0)
})
it('FE-COMP-ROUTECALCULATOR-061: one way back is offered a second only for a class the trip does not avoid yet', async () => {
const asked: Array<Record<string, number>> = []
server.use(http.post(VALHALLA, async ({ request }) => {
const body = await request.json() as { alternates?: number; costing_options: { auto: Record<string, number> } }
asked.push(body.costing_options.auto)
if (body.alternates) return HttpResponse.json({ trip: trip(DIRECT, { has_toll: true }) })
// Asked to leave out the tolls as well: a road that really is without them.
return HttpResponse.json({ trip: trip(NORTH, { time: 5000, has_toll: false }) })
}))
const routes = await calculateAlternatives({ lat: 61, lng: 10.5 }, { lat: 61, lng: 12 }, 'driving', { avoid: ['motorway'] })
// Motorways are avoided already, so the first extra question is about tolls, and it
// keeps the trip's own class in.
expect(asked).toEqual([{ use_highways: 0 }, { use_highways: 0, use_tolls: 0 }])
expect(routes).toHaveLength(2)
expect(routes[1]).toMatchObject({ avoids: 'toll', engine: 'valhalla', duration: 5000 })
})
it('FE-COMP-ROUTECALCULATOR-062: only a driving leg is weighed; a walk is asked of OSRM as before', async () => {
let valhalla = 0
server.use(
http.get(`${FOSSGIS.walking}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 61], [12, 61]] }, distance: 1000, duration: 900 }] })),
http.post(VALHALLA, () => { valhalla++; return HttpResponse.json({ trip: trip(DIRECT) }) }),
)
const routes = await calculateAlternatives(from, to, 'walking', { avoid: ['toll'] })
expect(valhalla).toBe(0)
expect(routes).toHaveLength(1)
expect(routes[0].engine).toBeUndefined()
expect(avoidedClasses('walking', ['toll'])).toEqual([])
expect(avoidedClasses('driving', ['toll', 'ferry'])).toEqual(['ferry', 'toll'])
expect(routeEngineFor('driving', ['toll'])).toBe('valhalla')
expect(routeEngineFor('driving', [])).toBe('osrm')
expect(routeEngineFor('plugin:ev/fast', ['toll'])).toBe('plugin')
})
it('FE-COMP-ROUTECALCULATOR-063: an OSRM stand-in says nothing was avoided, and is not filed as the avoided road', async () => {
const [a, b] = freshWaypoints()
let osrm = 0
let valhallaUp = false
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildLegsResponse()) }),
http.post(VALHALLA, () => (valhallaUp
? HttpResponse.json({ trip: { legs: [{ shape: encode6([[a.lat, a.lng], [b.lat, b.lng]]), summary: { length: 5, time: 700, has_toll: false, has_ferry: true } }] } })
: new HttpResponse(null, { status: 503 }))),
)
const fallback = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
expect(osrm).toBe(1)
expect(fallback.avoidance).toEqual({ asked: ['toll'], achieved: [], fellBack: true })
expect(fallback.distance).toBe(4200)
// Valhalla is back: the same drive asks it again instead of keeping the OSRM answer.
valhallaUp = true
const weighed = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
expect(weighed.avoidance).toEqual({ asked: ['toll'], achieved: ['toll'] })
expect(weighed.distance).toBe(5000)
expect(weighed.hasFerry).toBe(true)
// The stand-in was filed where it is true: as the plain OSRM answer, which an ordinary
// caller now gets without asking again, and without the avoidance attached.
const plain = await calculateRouteWithLegs([a, b])
expect(osrm).toBe(1)
expect(plain.distance).toBe(4200)
expect(plain.avoidance).toBeUndefined()
})
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
// The shape of a leg bent by a via: every point of the plain road lies on it, so
// asked only one way round the two read as one.
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 }
expect(sameRoad(plainRoad, spur)).toBe(false)
expect(sameRoad(spur, plainRoad)).toBe(false)
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true)
})
it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
// exactly between those samples, 1.8 km from the nearest, on the very same road.
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
const sparse: [number, number][] = [
[50, 0],
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
[50, 10],
]
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
expect(sameRoad(a, b)).toBe(true)
const off = furthestFrom([[50.02, 5]], dense)
expect(off?.index).toBe(0)
expect(off?.km).toBeCloseTo(2.22, 1)
})
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
http.post(VALHALLA, () => HttpResponse.json({ trip: trip([[63, 10], [63.6, 11], [63, 12]], { has_highway: false, has_ferry: true }) })),
)
const routes = await calculateAlternatives({ lat: 63, lng: 10 }, { lat: 63, lng: 12 })
expect(routes).toHaveLength(2)
expect(routes[1]).toMatchObject({ avoids: 'motorway', engine: 'valhalla', hasFerry: true })
})
})
+49 -227
View File
@@ -4,7 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
import type { RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
@@ -216,33 +216,6 @@ export function parsePluginProfile(profile: string): { pluginId: string; profile
return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) }
}
/**
* Which engine priced a route: OSRM, the second engine that weighs road classes away, or
* a route provider plugin. Their figures are three different speed models, so a figure
* from one read against a figure from another measures the models, not the roads.
*/
export type RouteEngine = 'osrm' | 'valhalla' | 'plugin'
/**
* The classes a request really weighs away, in a stable order, and none when it cannot.
*
* Only the driving profile has classes to leave out and only Valhalla can leave them out,
* so every other request is asked plainly whatever the trip says. One function for the
* day routing and for the offers beside it, so the two cannot disagree about which engine
* a leg belongs to.
*/
export function avoidedClasses(profile: RouteProfileKey, avoid: readonly AvoidClass[]): AvoidClass[] {
return profile === 'driving' && avoid.length > 0 && valhallaAvailable()
? [...avoid].sort((a, b) => a.localeCompare(b))
: []
}
/** The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to. */
export function routeEngineFor(profile: RouteProfileKey, avoid: readonly AvoidClass[]): RouteEngine {
if (parsePluginProfile(profile)) return 'plugin'
return avoidedClasses(profile, avoid).length ? 'valhalla' : 'osrm'
}
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
export async function calculateRoute(
waypoints: Waypoint[],
@@ -507,7 +480,9 @@ export async function calculateRouteWithLegs(
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const avoiding = avoidedClasses(profile, avoid)
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
? [...avoid].sort((a, b) => a.localeCompare(b))
: []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is
@@ -522,30 +497,23 @@ export async function calculateRouteWithLegs(
// way is worse: this Map is shared with the ordinary day planner, the studio and the
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
// under the plain key would hand them a road nobody drives and a second engine's
// times. So the avoiding key only ever holds a Valhalla answer: when Valhalla cannot
// answer and OSRM drives the request after all, that answer is filed under the plain
// key, where it is exactly what an ordinary caller would have got. Filed under the
// avoiding key it stood in for the avoided road until the next reload, however soon
// Valhalla came back.
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
// also separates the two engines.
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
const cacheKey = `${plainKey}${avoidScope}`
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
if (avoiding.length) {
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
// day drawn rather than blank, and `avoidance` stays absent on that result so the
// rail can say the avoidance did not happen instead of implying it did.
if (routed) {
cacheRoute(cacheKey, routed)
return routed
}
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
// than blank, and the answer says that nothing was avoided, with the reason, so the
// rail flags the day instead of implying the setting held on a road nobody weighed.
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(plainKey, plain)
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
}
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
@@ -590,18 +558,6 @@ export async function calculateRouteWithLegs(
return result
}
const result = await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(cacheKey, result)
return result
}
/** The OSRM answer for a chain, one leg per waypoint pair. Throws like every OSRM call here. */
async function osrmWithLegs(
waypoints: Waypoint[],
coords: string,
profile: RouteProfileKey,
signal?: AbortSignal,
): Promise<RouteWithLegs> {
// Written as literals rather than narrowing `profile`: its type is an open string union
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
const osrmProfile: 'driving' | 'walking' | 'cycling' =
@@ -636,7 +592,9 @@ async function osrmWithLegs(
)
const snapped = readSnapped(data, waypoints)
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
cacheRoute(cacheKey, result)
return result
}
/** Store a route and drop the oldest once the cache is over its cap. */
@@ -701,7 +659,6 @@ async function routeAvoidingWithLegs(
legs,
snapped: run.snapped,
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
hasFerry: run.total.hasFerry,
}
}
@@ -757,7 +714,7 @@ function formatDuration(seconds: number): string {
return `${m} min`
}
/** One way of driving a leg, as a router offers it. */
/** One way of driving a leg, as OSRM offers it. */
export interface RouteAlternative {
coordinates: [number, number][]
distance: number
@@ -767,24 +724,20 @@ export interface RouteAlternative {
/** Set when this way exists because a road class was left out of it. */
avoids?: AvoidClass
/**
* Which engine priced this route. Absent means OSRM, which is what answers every route
* of a trip that avoids nothing, except the avoidance offer on a default install.
* Which engine priced this route, when it was not the one that priced the rest.
*
* It exists because the engines do not agree on speed: measured over twelve European
* legs the per-leg drive times run from 14.7 % under OSRM's to 13.2 % over, and the
* sign depends on the region: Valhalla is faster on Spanish autovía and slower through
* a city. So a figure from one of them subtracted from a figure from the other is not a
* difference in driving time, it is the gap between two speed models, and the reader
* has no way of telling the two apart.
* Absent for everything OSRM answered, which is every route in a list except the
* avoidance offer on a default install. It exists because the two engines do not
* agree on speed: measured over twelve European legs the per-leg drive times run
* from 14.7 % under OSRM's to 13.2 % over, and the sign depends on the region:
* Valhalla is faster on Spanish autovía and slower through a city. So a figure from
* one of them subtracted from a figure from the other is not a difference in
* driving time, it is the gap between two speed models, and the reader has no way
* of telling the two apart.
*/
engine?: RouteEngine
/** Whether the way crosses by ferry, where the engine that drew it says so. */
hasFerry?: boolean
engine?: 'valhalla'
}
/** What two routes are compared by: their line, and their figures where there is none. */
export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'duration'>
/**
* Road classes worth asking the router to leave out, in the order they are tried.
*
@@ -827,16 +780,10 @@ const SAME_ROAD_KM = 0.5
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
*
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
* whole of `b` and then turns off to a point and back passed as `b`, because every point
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
* the check behind a choice both have to tell it from the plain road.
*/
export function sameRoad(a: RoadLine, b: RoadLine): boolean {
const there = furthestFrom(b.coordinates, a.coordinates)
const back = furthestFrom(a.coordinates, b.coordinates)
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
const stray = furthestFrom(b.coordinates, a.coordinates)
if (stray) return stray.km < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
@@ -915,18 +862,12 @@ async function valhallaExcluding(
// and every routing request an instance makes still lands in the one counter.
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
if (!leg || !legAvoids(leg, exclude)) return null
return valhallaOffer(leg)
}
/** A Valhalla leg as an offer, carrying the engine that priced it and what it crosses. */
function valhallaOffer(leg: ValhallaLeg): RouteAlternative {
return {
coordinates: leg.coordinates,
distance: leg.distance,
duration: leg.duration,
divergence: null,
engine: 'valhalla',
hasFerry: leg.hasFerry,
}
}
@@ -976,89 +917,12 @@ async function osrmExcluding(
* It is what makes a choice persistable: saving that point as a via forces the router back
* onto this road on every future request, without storing a polyline that would go stale
* with the next OSM update.
*
* `avoid` is the classes the trip weighs away. A driving leg of such a trip is routed by
* Valhalla, so its offers come from Valhalla too, weighed the same way, and never from
* OSRM: a list holding both engines is what offered a road the rail was not on as the
* quicker one, and clicking it changed nothing.
*/
export async function calculateAlternatives(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal, limit = 3, avoid = [] }: { signal?: AbortSignal; limit?: number; avoid?: readonly AvoidClass[] } = {},
): Promise<RouteAlternative[]> {
const avoiding = avoidedClasses(profile, avoid)
const routes = avoiding.length
? await alternativesAvoiding(from, to, avoiding, limit, signal)
: await alternativesFromOsrm(from, to, profile, limit, signal)
if (!routes.length) return routes
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes
}
/**
* The offers for a leg of a trip that weighs classes away, every one of them Valhalla's.
*
* Throws when Valhalla has nothing to say, so the picker reports a router that is not
* answering. Quietly offering OSRM's roads instead is the very mix this path exists to end.
*
* One way back gets the question the OSRM path asks too: the same drive with one more
* class left out, only of those the trip does not already avoid, and only where the road
* that comes back really is without it. Paced like the pieces of a split day, because the
* public instance allows one request a second.
*/
async function alternativesAvoiding(
from: Waypoint,
to: Waypoint,
avoiding: AvoidClass[],
limit: number,
signal?: AbortSignal,
): Promise<RouteAlternative[]> {
const sample = {
profile: 'driving' as const,
surface: 'alternatives' as const,
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
waypoints: 2,
km: haversineKm(from, to),
}
const answered = await valhallaAlternates(from, to, 'driving', avoiding, Math.max(0, limit - 1), signal)
if (!signal?.aborted) countRoute({ ...sample, failed: !answered })
if (!answered?.length) throw new Error('No route found')
const routes: RouteAlternative[] = []
for (const leg of answered) {
const offer = valhallaOffer(leg)
if (!routes.some(r => sameRoad(r, offer))) routes.push(offer)
}
if (routes.length >= 2) return routes
for (const cls of EXCLUDABLE_CLASSES) {
if (avoiding.includes(cls)) continue
await valhallaSpacing(signal)
if (signal?.aborted) break
const run = await valhallaRun([from, to], 'driving', [...avoiding, cls], signal)
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
if (!run || !legAvoids(run.total, cls)) continue
const detour = valhallaOffer(run.total)
if (routes.some(r => sameRoad(r, detour))) continue
routes.push({ ...detour, avoids: cls })
break
}
return routes
}
/** The offers for a leg of a trip that avoids nothing: OSRM's, and one avoidance offer. */
async function alternativesFromOsrm(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
limit: number,
signal?: AbortSignal,
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
@@ -1096,6 +960,11 @@ async function alternativesFromOsrm(
}
}
}
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes
}
@@ -1113,78 +982,31 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*
* `index` is where on `line` the point sits, so points taken off one line can be put back
* in the order that line drives through them.
*/
export function furthestFrom(
function furthestFrom(
line: [number, number][],
reference: [number, number][],
): { index: number; lat: number; lng: number; km: number } | null {
): { lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let bestIndex = -1
let best: { lat: number; lng: number } | null = null
let bestNeighbour: [number, number] | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const near = nearestOn(line[i], reference, step)
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
const [lat, lng] = line[i]
let nearest = Infinity
let nearestPoint: [number, number] | null = null
for (let j = 0; j < reference.length; j += step) {
const dLat = lat - reference[j][0]
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
const d = dLat * dLat + dLng * dLng
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
}
if (bestIndex < 0) return null
const [lat, lng] = line[bestIndex]
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { index: bestIndex, lat, lng, km }
}
/**
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
* degrees and the spot on the line it was measured to.
*
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
* on the very same road sat up to half a sample apart from it, which on a thousand
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
*/
function nearestOn(
point: [number, number],
reference: [number, number][],
step: number,
): { d: number; at: [number, number] } {
const cos = Math.cos((point[0] * Math.PI) / 180)
const squared = (p: [number, number]): number => {
const dLat = point[0] - p[0]
const dLng = (point[1] - p[1]) * cos
return dLat * dLat + dLng * dLng
}
let nearestIndex = 0
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const d = squared(reference[j])
if (d < nearest) { nearest = d; nearestIndex = j }
}
let at = reference[nearestIndex]
const last = Math.min(reference.length - 1, nearestIndex + step)
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
const d = squared(on)
if (d < nearest) { nearest = d; at = on }
}
return { d: nearest, at }
}
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
function closestOnSegment(
p: [number, number],
a: [number, number],
b: [number, number],
cos: number,
): [number, number] {
const dx = (b[1] - a[1]) * cos
const dy = b[0] - a[0]
const length = dx * dx + dy * dy
if (length === 0) return a
const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length))
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
if (!best) return null
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { ...best, km }
}
@@ -145,58 +145,4 @@ describe('buildDayRouteRuns', () => {
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
describe('a moving day from one stay to the next (#2476)', () => {
// Day 3 checks out of a hotel in Munich and into one in Hamburg, with a flight
// in between. The booked Hamburg night wrote its own (hidden) stop onto day 3.
const HOTEL_A = { lat: 48.137, lng: 11.575 }
const HOTEL_B = { lat: 53.551, lng: 9.993 }
const MUC = { lat: 48.353, lng: 11.786 }
const HAM = { lat: 53.63, lng: 9.988 }
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
const stays = [
{ id: 1, trip_id: 1, place_lat: HOTEL_A.lat, place_lng: HOTEL_A.lng, start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00' },
{ id: 2, trip_id: 1, place_lat: HOTEL_B.lat, place_lng: HOTEL_B.lng, start_day_id: 3, end_day_id: 5, check_in: '15:00', check_out: '11:00' },
] as unknown as Accommodation[]
const bookedStop = buildAssignment({ day_id: 3, order_index: 0, accommodation_id: 2, place: buildPlace({ ...HOTEL_B }) })
const flight = (located: boolean) => ({
id: 7, trip_id: 1, type: 'flight', title: 'LH 2078', status: 'confirmed', day_id: 3, end_day_id: 3,
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20',
endpoints: located
? [{ role: 'from', sequence: 0, name: 'MUC', ...MUC }, { role: 'to', sequence: 1, name: 'HAM', ...HAM }]
: [],
}) as unknown as Reservation
const movingDay = (reservations: Reservation[], dayStops = [bookedStop]) =>
buildDayRouteRuns(3, inputs({
days, assignments: { '3': dayStops }, reservations, accommodations: stays, optimizeFromAccommodation: true,
})).map(run => run.map(p => [p.lat, p.lng]))
it('FE-MAP-DRP-011: a flight saved without airports draws no drive from one hotel to the other', () => {
// Nothing located is left on the day, and the flight is still the move: a road
// from Munich to Hamburg is exactly the trip that did not happen.
expect(movingDay([flight(false)])).toEqual([])
})
it('FE-MAP-DRP-012: with its airports the flight splits the day at them, as reported', () => {
expect(movingDay([flight(true)])).toEqual([
[[HOTEL_A.lat, HOTEL_A.lng], [MUC.lat, MUC.lng]],
[[HAM.lat, HAM.lng], [HOTEL_B.lat, HOTEL_B.lng]],
])
})
it('FE-MAP-DRP-013: without any booking the move is still the drive between the two hotels (#1297)', () => {
expect(movingDay([])).toEqual([[[HOTEL_A.lat, HOTEL_A.lng], [HOTEL_B.lat, HOTEL_B.lng]]])
})
it('FE-MAP-DRP-014: a hotel stop planned by hand ahead of the flight is reached after landing', () => {
// The traveller put the Hamburg hotel on the day before booking it, so the stop
// stays visible and carries no time. The flight still seats itself in front of
// the place it lands next to, and no road runs from Munich to Hamburg.
const handPlaced = at(HOTEL_B.lat, HOTEL_B.lng, 0, { day_id: 3 })
expect(movingDay([flight(true)], [handPlaced])).toEqual([
[[HOTEL_A.lat, HOTEL_A.lng], [MUC.lat, MUC.lng]],
[[HAM.lat, HAM.lng], [HOTEL_B.lat, HOTEL_B.lng]],
])
})
})
})
+2 -7
View File
@@ -1,7 +1,7 @@
import { withHotelBookends } from './RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '@trek/shared/roadtrip'
import { withinDriveRange } from '../../utils/geo'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
@@ -170,12 +170,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
// Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to.
// Not when a flight, train, ferry or coach is booked on the day, located or not:
// that booking IS the move, and the road from one hotel to the other is exactly
// the stretch nobody drove (#2476). One saved without its stations leaves no
// waypoint behind, so the gates above never see it; no line beats a wrong one.
const dayHasCarrierBooking = dayTransports.some(r => isCarrierTransport(r))
if (runsWithHotel.length === 0 && drawMorning && drawEvening && !dayHasCarrierBooking) {
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
const m = hotelPt(bookends?.morning)
const e = hotelPt(bookends?.evening)
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { selectedPlaceTarget } from './selectedPlaceTarget'
// FE-MAP-SELTARGET-001 to FE-MAP-SELTARGET-004
describe('selectedPlaceTarget', () => {
const pin = { id: 1, lat: 48, lng: 2 }
const stop = { id: 2, lat: 49, lng: 3 }
const stay = { id: 3, lat: 50, lng: 4 }
it('FE-MAP-SELTARGET-001: goes to the pin the map shows', () => {
expect(selectedPlaceTarget(1, [pin], [stop], stay)).toBe(pin)
})
it('FE-MAP-SELTARGET-002: goes to the open day\'s stop when a filter hides its pin', () => {
expect(selectedPlaceTarget(2, [pin], [stop], null)).toBe(stop)
})
it('FE-MAP-SELTARGET-003: goes to a stay that is neither a pin nor a stop of the day', () => {
expect(selectedPlaceTarget(3, [pin], [stop], stay)).toBe(stay)
})
it('FE-MAP-SELTARGET-004: goes nowhere without a selection, or for a place it does not know', () => {
expect(selectedPlaceTarget(null, [pin], [stop], stay)).toBeNull()
expect(selectedPlaceTarget(9, [pin], [stop], stay)).toBeNull()
})
})
@@ -1,27 +0,0 @@
/** A place the map can go to. */
export interface MapTarget {
id: number
lat?: number | null
lng?: number | null
}
/**
* Where the map goes when a place is selected, the same in both renderers.
*
* Its pin first, then the open day's stop, then the selected place itself. A filter
* that hides the pin (Unplanned, a category) keeps the pin off the map but not the
* map away from the place, and a stay shown as a chip in the day list is neither a
* pin under Unplanned nor one of the day's stops, so a click on it used to leave the
* map where it was while a click on a stop moved it (#2488).
*/
export function selectedPlaceTarget<P extends MapTarget>(
selectedPlaceId: number | null | undefined,
places: P[],
dayPlaces: P[],
selectedPlace?: P | null,
): P | null {
if (!selectedPlaceId) return null
return places.find(p => p.id === selectedPlaceId)
?? dayPlaces.find(p => p.id === selectedPlaceId)
?? (selectedPlace?.id === selectedPlaceId ? selectedPlace : null)
}
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dayColor } from '../Roadtrip/dayColors'
import { dayRouteColor, planTripRoute, routeTrip, summariseTripRoute } from './tripRouteGeometry'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Reservation, RouteSegment } from '../../types'
import type { AssignmentsMap, RouteSegment } from '../../types'
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>()
@@ -212,24 +212,4 @@ describe('tripRouteGeometry', () => {
vi.useRealTimers()
}
})
it('FE-MAP-TRG-012: the overview draws no drive between two stays on the day a flight connects them (#2476)', () => {
// Day 2 moves from a Munich hotel to a Hamburg one. The overview reads the same
// day plan as the map, so the flight saved without airports keeps the road off it.
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
const accommodations = [
{ id: 1, trip_id: 1, place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 },
{ id: 2, trip_id: 1, place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 3 },
] as unknown as Accommodation[]
const flight = {
id: 80, trip_id: 1, type: 'flight', title: 'LH 2078', day_id: 2, end_day_id: 2,
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20', endpoints: [],
} as unknown as Reservation
const moving = { days, assignments: {}, accommodations, optimizeFromAccommodation: true }
expect(planTripRoute({ ...moving, reservations: [flight] }, 'driving')).toEqual([])
// Without the booking the day is a drive from one hotel to the other, as before (#1297).
const byRoad = planTripRoute({ ...moving, reservations: [] }, 'driving')
expect(byRoad.map(d => d.day.id)).toEqual([2])
})
})
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
@@ -365,62 +365,3 @@ describe('valhallaRun over a whole day', () => {
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
})
})
describe('valhallaAlternates', () => {
/** The same leg read three ways, the second of them crossing by ferry. */
const withAlternates = (alternates: unknown[]) => ({
...answer({ time: 11_460 }),
alternates,
})
it('VALHALLA-ALT-001: reads the preferred way, then every alternate, each with its own flags', () => {
const ways = alternatesFrom(withAlternates([
answer({ time: 12_000, has_ferry: true }),
answer({ time: 13_000, has_toll: true }),
]))
expect(ways?.map(w => w.duration)).toEqual([11_460, 12_000, 13_000])
expect(ways?.map(w => w.hasFerry)).toEqual([false, true, false])
expect(ways?.map(w => w.hasToll)).toEqual([false, false, true])
expect(ways?.[1].coordinates).toEqual(SHAPE_POINTS)
})
it('VALHALLA-ALT-002: an alternate that cannot be read is dropped, a preferred way that cannot is no answer', () => {
// One bad entry is no reason to lose the leg's other ways, but the others are offered
// against the preferred one and mean nothing without it.
expect(alternatesFrom(withAlternates([{ trip: { legs: [] } }, answer({ time: 13_000 })]))?.map(w => w.duration))
.toEqual([11_460, 13_000])
expect(alternatesFrom(answer())).toHaveLength(1)
expect(alternatesFrom({ alternates: [answer()] })).toBeNull()
expect(alternatesFrom({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
})
it('VALHALLA-ALT-003: asks for the leg between exactly its two ends, weighed the way the trip is, with the number of alternates', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json(withAlternates([answer({ time: 12_000 })]))
}))
const ways = await valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll', 'ferry'], 2)
await valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 0)
expect(ways).toHaveLength(2)
expect(bodies[0]).toMatchObject({ alternates: 2, costing: 'auto', costing_options: { auto: { use_tolls: 0, use_ferry: 0 } } })
expect(bodies[0].locations).toHaveLength(2)
// No alternates asked for, none named: the request stays the plain one.
expect(bodies[1]).not.toHaveProperty('alternates')
})
it('VALHALLA-ALT-004: a refusal is asked once more, then is no answer; an instance with its own OSRM asks nothing', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return new HttpResponse(null, { status: 429 }) }))
await expect(valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 2)).resolves.toBeNull()
expect(calls).toBe(2)
setSettings({ routing_base_url: 'https://osrm.example.org' })
await expect(valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 2)).resolves.toBeNull()
expect(calls).toBe(2)
})
})
+15 -90
View File
@@ -204,7 +204,14 @@ export async function valhallaRun(
// rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a
// host that allows one request a second.
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal), signal)
let run = await requestRun(base, chunk, profile, avoid, signal)
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
// Not when the host just proved it is not a Valhalla at all: that answer will not
// change on a second ask, and asking anyway is the request the memo exists to save.
if (notValhalla.has(base)) break
await pause(CHUNK_PAUSE_MS, signal)
run = await requestRun(base, chunk, profile, avoid, signal)
}
// A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap.
@@ -253,39 +260,14 @@ function pause(ms: number, signal?: AbortSignal): Promise<void> {
})
}
/**
* Waits out the host's one request a second before the next question is put.
*
* For a caller that asks this engine several things in a row on its own, such as the
* offers for one leg: the same pause the pieces of a split day keep, so a picker cannot
* fire at a pace the day routing was careful never to.
*/
export function valhallaSpacing(signal?: AbortSignal): Promise<void> {
return pause(CHUNK_PAUSE_MS, signal)
}
/**
* One question, and one more try after a pause when it earned no answer.
*
* Not when the host just proved it is not a Valhalla at all: that answer will not change
* on a second ask, and asking anyway is the request the memo exists to save.
*/
async function askWithRetry<T>(base: string, ask: () => Promise<T | null>, signal?: AbortSignal): Promise<T | null> {
let answer = await ask()
for (let attempt = 0; !answer && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
if (notValhalla.has(base)) break
await pause(CHUNK_PAUSE_MS, signal)
answer = await ask()
}
return answer
}
/** The request body for a chain of waypoints, weighted away from `avoid`. */
function routeBody(
/** One request, at most ten locations. */
async function requestRun(
base: string,
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
): Record<string, unknown> {
signal?: AbortSignal,
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to
@@ -293,16 +275,13 @@ function routeBody(
// away together answered 656.9 km against 339.2 km for the plain route.
const options: Record<string, number> = {}
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
return {
const body = {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
}
}
/** The parsed answer to one POST, or null for every failure. */
async function postRoute(base: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
try {
const response = await fetch(`${base}/route`, {
method: 'POST',
@@ -320,66 +299,12 @@ async function postRoute(base: string, body: Record<string, unknown>, signal?: A
if (response.status === 404 || response.status === 405) notValhalla.add(base)
return null
}
return await response.json()
return runFrom(await response.json())
} catch {
return null
}
}
/** One request, at most ten locations. */
async function requestRun(
base: string,
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid), signal)
return data === null ? null : runFrom(data)
}
/**
* The ways of driving one leg this engine would offer, its preferred one first, or null.
*
* The offers for a trip that avoids something have to come from the engine that routes
* that trip's days, or the list and the rail describe two different drives: OSRM's
* quickest road beside a day Valhalla had routed was offered as the faster way, and
* taking it changed nothing. Valhalla answers `alternates` between exactly two locations,
* which is all one leg is, and weighs every one of them the way it weighed the day.
*/
export async function valhallaAlternates(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
count: number,
signal?: AbortSignal,
): Promise<ValhallaLeg[] | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base)) return null
const body = { ...routeBody([from, to], profile, avoid), ...(count > 0 ? { alternates: count } : {}) }
return askWithRetry(base, async () => {
const data = await postRoute(base, body, signal)
return data === null ? null : alternatesFrom(data)
}, signal)
}
/**
* Every way in an answer that asked for alternates: `trip` first, then each
* `alternates[].trip`, read the way `runFrom` reads a single leg.
*
* Null when the preferred one cannot be read, because the others are offered against it.
* An alternate that cannot be read is dropped on its own: one bad entry is no reason to
* lose the leg's other ways.
*/
export function alternatesFrom(data: unknown): ValhallaLeg[] | null {
const primary = runFrom(data)
if (!primary) return null
const extra = (data as { alternates?: unknown })?.alternates
const others = Array.isArray(extra) ? extra.map(alt => runFrom(alt)?.total ?? null) : []
return [primary.total, ...others.filter((leg): leg is ValhallaLeg => leg !== null)]
}
/**
* Several legs read as one, the way a caller that only wants the whole drive sees it.
*
+1 -34
View File
@@ -31,12 +31,6 @@ function getIframe(): HTMLIFrameElement | null {
return document.querySelector('#pdf-preview-overlay iframe')
}
/** The cover's "Planned" figure. The stat is read off its label, since days and places print the same markup. */
function plannedStat(html: string): number | null {
const match = /<div class="cover-stat-num">(\d+)<\/div>\s*<div class="cover-stat-lbl">pdf\.planned<\/div>/.exec(html)
return match ? Number(match[1]) : null
}
// ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
@@ -263,7 +257,7 @@ describe('downloadTripPDF', () => {
// The desktop toolbar hands the export the store's assignments, hidden stop and
// all, and that stop heads its check-in day. The day plan hides it and shows the
// booking as the accommodation block, and the print lists what the plan lists.
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null, price: '120' } as any
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null } as any
const hotelStop = { id: 201, day_id: 10, place_id: 101, order_index: 0, accommodation_id: 30, place: hotelPlace }
const flight = { ...transportReservation, title: 'Morning flight', reservation_time: '2025-06-01T08:00:00' }
await downloadTripPDF({
@@ -275,33 +269,6 @@ describe('downloadTripPDF', () => {
expect(html).not.toContain('Seewartenstr. 9')
expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum'))
expect(html.indexOf('Morning flight')).toBeGreaterThan(-1)
// The cover and the day header read the same list: the hidden stop is not a planned
// stop and its hotel's price is not part of the day, whichever shell asked.
expect(plannedStat(html)).toBe(1)
const money = html.replace(/[\u00a0\u202f]/g, ' ')
expect(money).toContain('15,00 €')
expect(money).not.toContain('135,00 €')
})
it('FE-COMP-TRIPPDF-013g: a service stop the plan keeps to the road trip view is not printed either, and comes back with the switch', async () => {
// "Show in Days too" off keeps petrol stations and rest areas out of the day lists.
// The print follows the same switch, so the desktop, which hands over the store's
// assignments, and the phone print the same day.
const pump = { id: 102, name: 'Aral Autohof', address: 'Autobahnkreuz 1', stop_type: 'fuel', place_time: null } as any
const pumpStop = { id: 202, day_id: 10, place_id: 102, order_index: 1, place: pump }
const assignments = { '10': [assignmentForDay, pumpStop] } as any
await downloadTripPDF({ ...richArgs, assignments, showServiceStops: false })
const hidden = getIframe()!.srcdoc
expect(hidden).not.toContain('Aral Autohof')
expect(hidden).toContain('Colosseum')
expect(plannedStat(hidden)).toBe(1)
// On, and by default, the stop prints where the plan lists it.
getOverlay()?.remove()
await downloadTripPDF({ ...richArgs, assignments })
const shown = getIframe()!.srcdoc
expect(shown).toContain('Aral Autohof')
expect(plannedStat(shown)).toBe(2)
})
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
+10 -31
View File
@@ -16,7 +16,6 @@ import { renderTripMapImage } from './tripMapImage'
import { formatDistance } from '../../utils/units'
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
/**
* Every day starts a new page by default. On a trip of short days that prints
@@ -200,34 +199,11 @@ interface downloadTripPDFProps {
timeFormat?: string
/** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */
distanceUnit?: string
/**
* The road trip setting "Show in Days too" (`roadtrip_service_stops_in_days`), read as a
* prop for the same reason. Off, the day plan keeps petrol stations and rest areas to
* the road trip view, and so does the print.
*/
showServiceStops?: boolean
}
/**
* The stops the day plan lists, out of everything the store holds for the trip.
*
* The stop a booked night wrote onto its check-in day is out whatever the switch says:
* the day already shows that booking as its accommodation block, and the row would be
* the same hotel a second time. The service stops go with the setting. One predicate
* for the whole document, so the day lists, the route map, the cover's planned count
* and every cost total agree with the plan and with each other, whichever shell asked.
*/
function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean): AssignmentsMap {
return Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [
dayId,
(list || []).filter(a => a.accommodation_id == null && (showServiceStops || !isServiceStopType(a.place?.stop_type))),
]))
}
// `assignments` is normalised here once, to the plan's own list; every read below
// (and fetchPlacePhotos) relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments: stored = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true }: downloadTripPDFProps) {
const assignments = planAssignments(stored, showServiceStops)
// `assignments` is normalised here once — every read below (and fetchPlacePhotos)
// relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit }: downloadTripPDFProps) {
const breaksPerDay = pageBreakPerDay()
const loc = _locale || undefined
const tr = _t || (k => k)
@@ -359,11 +335,14 @@ export async function downloadTripPDF({ trip, days, places, assignments: stored
}
// Build day HTML
const daysHtml = sorted.map((day, di) => {
// What the plan lists (planAssignments): the stop a booked night wrote sat at the
// head of its day, so the hotel printed above a morning flight (#2434).
// Without the stop a booked night wrote onto its check-in day: the day plan hides
// it (the day already carries the booking as its accommodation block) and the
// export lists what the plan lists. The desktop toolbar hands the export the
// store's own assignments, filter and all, and the stop sits at the head of its
// day, so the hotel printed above a morning flight (#2434).
const assigned = (assignments[String(day.id)] || [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.filter((a: any) => a.accommodation_id == null)
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
@@ -1,103 +0,0 @@
import { useId, useState } from 'react'
import { CalendarPlus, Plus } from 'lucide-react'
import { dayDate } from '../../utils/dayLabel'
import type { DayAddControls } from '../../utils/dayAdd'
interface DayAddFooterProps {
/** Without it, or on a trip without dates, the footer keeps its single "Add day" button. */
dayAdd?: DayAddControls
onAddDay: () => void
onClose: () => void
t: (key: string, params?: Record<string, string | number>) => string
locale: string
}
/** One button size for the whole foot of the reorder dialog, the delete question's included. */
export const FOOTER_BTN =
'inline-flex min-h-9 flex-shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-body font-medium transition-colors disabled:cursor-default disabled:opacity-40'
/** Cancel, Close and the quiet add: one outline, one text colour, so none of them looks switched off beside another. */
export const SECONDARY_BTN = `${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover hover:text-content disabled:hover:bg-transparent`
const CLOSE_BTN = `${SECONDARY_BTN} ml-auto`
/** The add that matters most: drawn in the accent, not filled with it, so it leads without weighing on the row. */
const MAIN_BTN = `${FOOTER_BTN} border border-accent-on bg-transparent font-semibold text-accent-on hover:bg-accent-subtle disabled:hover:bg-transparent`
/**
* The foot of the reorder dialog: close it, or add a day.
*
* On a trip with dates there are two ways to add one, in one row with Close. The
* next calendar day, named on its button, extends the trip by one; a day without
* a date goes to the end and leaves the trip dates alone. The line above them
* says what the button in view does, and switches while the pointer or the focus
* rests on the other one. When no day can be added it says why instead.
*/
export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFooterProps) {
const [aboutUndated, setAboutUndated] = useState(false)
const hintId = useId()
const busy = dayAdd?.busy ?? false
const blocked = dayAdd?.blocked ?? null
const addOff = busy || !!blocked
const closeButton = (
<button type="button" onClick={onClose} className={CLOSE_BTN}>
{t('common.close')}
</button>
)
if (!dayAdd?.nextDate) {
return (
<div className="flex flex-col gap-2.5">
{blocked && <p className="m-0 text-caption text-content-muted">{blocked}</p>}
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={onAddDay} disabled={addOff} className={MAIN_BTN}>
<Plus size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.addDay')}
</button>
{closeButton}
</div>
</div>
)
}
const date = dayDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate
const hint = blocked
?? (aboutUndated ? t('dayplan.addUndatedDayHint') : dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date }))
const pointAtUndated = {
onMouseEnter: () => setAboutUndated(true),
onMouseLeave: () => setAboutUndated(false),
onFocus: () => setAboutUndated(true),
onBlur: () => setAboutUndated(false),
}
return (
<div className="flex flex-col gap-2.5">
<p id={hintId} aria-live="polite" className="m-0 text-caption text-content-muted">
{hint}
</p>
{/* Should a language run long, the row wraps between buttons, never inside one. */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={dayAdd.onAddDated}
disabled={addOff || !!dayAdd.datedBlocked}
aria-describedby={hintId}
className={MAIN_BTN}
>
<CalendarPlus size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.addDatedDay', { date })}
</button>
<button
type="button"
onClick={onAddDay}
disabled={addOff}
aria-describedby={hintId}
className={SECONDARY_BTN}
{...pointAtUndated}
>
<Plus size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.addUndatedDay')}
</button>
{closeButton}
</div>
</div>
)
}
@@ -1,4 +1,4 @@
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-085
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-080
import React from 'react';
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -12,7 +12,6 @@ import { usePluginStore } from '../../store/pluginStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
@@ -520,15 +519,6 @@ describe('DayDetailPanel', () => {
expect(await screen.findByText('102 Quai de Jemmapes')).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-085: the hotel picker leaves out imported tracks', async () => {
const hotel = buildPlace({ id: 10, name: 'Hotel du Nord' });
const track = buildPlace({ id: 12, name: 'Canal Saint-Martin walk', route_geometry: '[[48.87,2.36],[48.88,2.37]]' });
render(<DayDetailPanel {...defaultProps} places={[hotel, track]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await screen.findByText('Hotel du Nord');
expect(screen.queryByText('Canal Saint-Martin walk')).not.toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-030: selecting a place in hotel picker enables save button', async () => {
const place = buildPlace({ id: 10, name: 'Maison Blanche' });
server.use(
@@ -1906,80 +1896,3 @@ describe('DayDetailPanel time format', () => {
expect(screen.getByText('11:00')).toBeInTheDocument();
});
});
// ── Blur booking codes: the stay's own code and the hotel editor (#2457) ────
describe('DayDetailPanel blur booking codes (#2457)', () => {
beforeEach(() => {
server.use(http.get('/api/view-contributions/:view/:tripId', () => HttpResponse.json({ contributions: [] })));
});
const blurOn = (on: boolean) => seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: on },
});
const stayWithCode = (confirmation: string) => server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Code Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation,
}],
})
),
);
const openHotelEditor = async () => {
await screen.findByText('Code Hotel');
// header collapse (0), header close (1), pencil (2), remove (3), as in DAYDETAIL-032
await userEvent.click(screen.getAllByRole('button')[2]);
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')?.textContent).toMatch(/Edit accommodation/i);
});
};
it('FE-PLANNER-DAYDETAIL-081: the confirmation code of the stay itself is blurred in the details grid', async () => {
blurOn(true);
stayWithCode('HOTEL-SECRET');
render(<DayDetailPanel {...defaultProps} />);
const code = await screen.findByText('HOTEL-SECRET');
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-DAYDETAIL-082: the confirmation field of the hotel editor is blurred until focused', async () => {
blurOn(true);
stayWithCode('HOTEL-SECRET');
render(<DayDetailPanel {...defaultProps} />);
await openHotelEditor();
const field = screen.getByDisplayValue('HOTEL-SECRET') as HTMLInputElement;
expect(isBlurred(field)).toBe(true);
act(() => field.focus());
expect(isBlurred(field)).toBe(false);
act(() => field.blur());
expect(isBlurred(field)).toBe(true);
});
it('FE-PLANNER-DAYDETAIL-083: with the setting off the stay code and the editor field stay plain', async () => {
blurOn(false);
stayWithCode('HOTEL-PLAIN');
render(<DayDetailPanel {...defaultProps} />);
expect(isBlurred(await screen.findByText('HOTEL-PLAIN'))).toBe(false);
await openHotelEditor();
expect(isBlurred(screen.getByDisplayValue('HOTEL-PLAIN'))).toBe(false);
});
it('FE-PLANNER-DAYDETAIL-084: a blurred code in the hotel editor still saves unchanged', async () => {
blurOn(true);
let body: Record<string, unknown> | null = null;
stayWithCode('HOTEL-SECRET');
server.use(
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ accommodation: { id: 1, place_id: 5, place_name: 'Code Hotel', start_day_id: 1, end_day_id: 3, confirmation: 'HOTEL-SECRET' } });
}),
);
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 5, name: 'Code Hotel' })]} />);
await openHotelEditor();
await userEvent.click(screen.getByText(/Save/i));
await waitFor(() => expect(body).not.toBeNull());
expect(body!.confirmation).toBe('HOTEL-SECRET');
});
});
@@ -13,7 +13,6 @@ import { useTripStore } from '../../store/tripStore'
import { applyStayStops } from '../../store/stayStops'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { BlurredCode, BookingCodeInput } from '../shared/BookingCode'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
@@ -21,7 +20,6 @@ import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../ty
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { formatClockTime, splitReservationDateTime } from '../../utils/formatters'
import { useDayDetail } from './useDayDetail'
import { stayPlaces } from '../../utils/stayPlaces'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -455,7 +453,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
)}
{acc.confirmation && (
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}><BlurredCode>{acc.confirmation}</BlurredCode></div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{acc.confirmation}</div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<Hash size={8} /> {t('day.confirmation')}
</div>
@@ -634,7 +632,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
</div>
<div style={{ flex: 2, minWidth: 120 }}>
<label style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.confirmation')}</label>
<BookingCodeInput value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
<input type="text" value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} />
</div>
</div>
@@ -661,8 +659,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
{/* Place List */}
<div style={{ maxHeight: 250, overflowY: 'auto' }}>
{(() => {
const offered = stayPlaces<Place>(places, hotelForm.place_id)
const filtered = hotelCategoryFilter ? offered.filter(p => p.category_id === hotelCategoryFilter) : offered
const filtered = hotelCategoryFilter ? places.filter(p => p.category_id === hotelCategoryFilter) : places
return filtered.length === 0 ? (
<div style={{ padding: 20, textAlign: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
) : filtered.map(p => (
@@ -101,15 +101,14 @@ vi.mock('../shared/Toast', () => ({
// ── Permissions mock ────────────────────────────────────────────────────────
// Flipped per test to render the read-only variants of the day rows; `denied`
// takes single actions away from an otherwise editing member.
const mockPermissions = vi.hoisted(() => ({ canEdit: true, denied: new Set<string>() }))
// Flipped per test to render the read-only variants of the day rows.
const mockPermissions = vi.hoisted(() => ({ canEdit: true }))
vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any
return {
...actual,
useCanDo: () => (action: string) => mockPermissions.canEdit && !mockPermissions.denied.has(action),
useCanDo: () => () => mockPermissions.canEdit,
}
})
@@ -183,7 +182,6 @@ beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
mockPermissions.canEdit = true
mockPermissions.denied.clear()
// clearAllMocks keeps implementations, so tests that swap the router out would
// otherwise leak into the ones after them.
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
@@ -940,18 +938,6 @@ describe('DayPlanSidebar', () => {
expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id)
})
it('FE-PLANNER-DAYPLAN-214: without place_edit the menu still removes from the day but neither edits nor deletes the place (#2446)', () => {
mockPermissions.denied.add('place_edit')
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
fireEvent.contextMenu(screen.getByText('Louvre Museum'))
expect(screen.getByText(/Remove from day/i)).toBeInTheDocument()
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
})
// ── Arrow reorder buttons ────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => {
@@ -2522,86 +2508,6 @@ describe('DayPlanSidebar', () => {
expect(stored!.day_plan_position).toBe(positions[1].day_plan_position)
})
// The crossing from the report behind #2461: Amsterdam and Newcastle on the day, the
// ferry from IJmuiden to the Port of Tyne in the evening. The slot written here is the
// one every other reader takes from then on, the road trip included.
const crossing = (newcastleAt: string | null, withAmsterdam = true) => {
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 newcastle = buildPlace({ id: 2, name: 'Newcastle', lat: 54.9783, lng: -1.6178, place_time: newcastleAt })
const stops = [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: amsterdam }),
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: newcastle }),
].filter(a => withAmsterdam || a.id !== 11)
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, newcastle], reservations: [ferry], assignments: { '10': stops },
})} />)
}
it('FE-PLANNER-DAYPLAN-218: a ferry between two untimed stops on its two shores is given the slot between them (#2461)', async () => {
const { reservationsApi } = await import('../../api/client')
crossing(null)
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
// By the clock alone it closed the day at 2.5, and the drive went overland first.
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
expect(useTripStore.getState().reservations.find(r => r.id === 69)!.day_plan_position).toBe(0.5)
})
it('FE-PLANNER-DAYPLAN-219: a stop timed before the ferry keeps it behind that stop (#2461)', async () => {
const { reservationsApi } = await import('../../api/client')
crossing('10:00')
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 1.5 }])
})
it('FE-PLANNER-DAYPLAN-220: a ferry landing next to the only stop of the day opens it (#2461)', async () => {
const { reservationsApi } = await import('../../api/client')
crossing(null, false)
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
// Ahead of Newcastle, which is stored at order_index 1 here.
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'))
@@ -2891,8 +2797,7 @@ describe('DayPlanSidebar', () => {
expect(onRouteRefresh).toHaveBeenCalled()
// The store carries the new slot per day so the merged list stays stable.
expect(useTripStore.getState().reservations[0].day_positions).toEqual({ 10: expect.any(Number) })
// Undoing restores the original assignment order; the step names its day, so deleting the day drops it.
expect(pushUndo.mock.calls[0][2]).toEqual([10])
// Undoing restores the original assignment order.
const undo = pushUndo.mock.calls[0][1] as () => Promise<void>
await undo()
expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12])
@@ -3425,65 +3330,6 @@ describe('DayPlanSidebar', () => {
expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 })
})
// #2455. The list draws a timed stop by its start, the road trip drives the stored
// order. A stop with a start moved onto another day has to be stored where the list
// will draw it, or the road trip visits it somewhere else and, behind a later start,
// reaches it late.
describe('a timed stop moved onto another day is stored where the list shows it (#2455)', () => {
const setup = (extra: Record<string, unknown> = {}) => {
const moveAssignment = vi.fn(async () => undefined)
stubTripActions({ moveAssignment })
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
]
const moved = buildPlace({ id: 1, name: 'Travemuende Strand', place_time: '10:00' })
const early = buildPlace({ id: 2, name: 'Luebeck Dom', place_time: '09:00' })
const middle = buildPlace({ id: 3, name: 'Wismar Hafen', place_time: '11:00' })
const late = buildPlace({ id: 4, name: 'Rostock Markt', place_time: '14:00' })
const assignments = {
'10': [buildAssignment({ id: 21, day_id: 10, order_index: 0, place: moved })],
'11': [
buildAssignment({ id: 31, day_id: 11, order_index: 0, place: early }),
buildAssignment({ id: 32, day_id: 11, order_index: 1, place: middle }),
buildAssignment({ id: 33, day_id: 11, order_index: 2, place: late }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, places: [moved, early, middle, late], assignments, ...extra })} />)
return moveAssignment
}
it('FE-PLANNER-DAYPLAN-215: dropped on the day body, it lands between the stops its start falls between', () => {
const moveAssignment = setup()
fireEvent.dragStart(dragRow(screen.getByText('Travemuende Strand')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(document.querySelectorAll('[style*="padding-top: 6px"]')[1], { dataTransfer: { getData: vi.fn(() => '') } })
// 10:00 sits between 09:00 (index 0) and 11:00, so index 1, not the end of the day.
expect(moveAssignment).toHaveBeenCalledWith(1, 21, 10, 11, 1)
})
it('FE-PLANNER-DAYPLAN-216: dropped on a later stop, it still lands where its start puts it', () => {
const moveAssignment = setup()
fireEvent.dragStart(dragRow(screen.getByText('Travemuende Strand')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dragRow(screen.getByText('Rostock Markt')), { dataTransfer: { getData: vi.fn(() => '') } })
expect(moveAssignment).toHaveBeenCalledWith(1, 21, 10, 11, 1)
})
it('FE-PLANNER-DAYPLAN-217: with the planner wired in, the move goes through it so the day keeps its vias', async () => {
// Landing in the middle of a day shifts the legs behind it, which only the planner
// can correct: it holds the vias. The list still decides where the stop goes.
const onMoveToDay = vi.fn(async () => undefined)
const pushUndo = vi.fn()
const moveAssignment = setup({ onMoveToDay, pushUndo })
fireEvent.dragStart(dragRow(screen.getByText('Travemuende Strand')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dayHeader('Day 2'), { dataTransfer: { getData: vi.fn(() => '') } })
expect(onMoveToDay).toHaveBeenCalledWith(21, 10, 11, 1)
expect(moveAssignment).not.toHaveBeenCalled()
// Still offered back once the planner has made the move, tagged with both days it touches.
await waitFor(() => expect(pushUndo).toHaveBeenCalledTimes(1))
expect(pushUndo.mock.calls[0][2]).toEqual([11, 10])
})
})
it('FE-PLANNER-DAYPLAN-149: the note context menu edits and asks before deleting', async () => {
const user = userEvent.setup()
const note = buildDayNote({ id: 70, day_id: 10, text: 'A note' })
@@ -3936,76 +3782,6 @@ describe('DayPlanSidebar', () => {
openSpy.mockRestore()
})
describe('a moving day with a flight between the two stays (#2476)', () => {
// Day 2 checks out of a Munich hotel and into a Hamburg one, and nothing else is
// planned on it. The booked night's own stop never reaches the list.
const movingDays = [
buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' }),
buildDay({ id: 11, date: '2026-11-04', title: 'Day 2' }),
buildDay({ id: 12, date: '2026-11-05', title: 'Day 3' }),
]
const stays: Accommodation[] = [
{ id: 1, trip_id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.137, place_lng: 11.575, place_name: 'Hotel A' },
{ id: 2, trip_id: 1, start_day_id: 11, end_day_id: 12, place_lat: 53.551, place_lng: 9.993, place_name: 'Hotel B' },
]
const airport = (role: 'from' | 'to', name: string, lat: number, lng: number) =>
({ role, sequence: role === 'from' ? 0 : 1, name, code: null, lat, lng, timezone: null, local_date: null, local_time: null })
const flight = (located: boolean) => buildReservation({
id: 77, type: 'flight', title: 'LH 2078', day_id: 11, end_day_id: 11,
reservation_time: '2026-11-04T15:15:00', reservation_end_time: '2026-11-04T17:20:00',
endpoints: located ? [airport('from', 'MUC', 48.353, 11.786), airport('to', 'HAM', 53.63, 9.988)] : [],
})
it('FE-PLANNER-DAYPLAN-222: offers no Google Maps or CoMaps route from one hotel to the other', () => {
for (const located of [true, false]) {
const { unmount } = render(<DayPlanSidebar {...makeDefaultProps({
days: movingDays, accommodations: stays, reservations: [flight(located)], selectedDayId: 11,
})} />)
// With its airports the route to and from them can still be shown; without
// them there is nothing left to draw, so the tools go as a whole instead of
// standing there dead. The hand-offs, which could only ever describe a drive
// from Munich to Hamburg, are gone either way.
if (located) expect(screen.getByRole('button', { name: 'Route' })).toBeInTheDocument()
else expect(screen.queryByRole('button', { name: 'Route' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open in CoMaps' })).not.toBeInTheDocument()
unmount()
}
})
it('FE-PLANNER-DAYPLAN-223: the hotel legs run to the departure airport and from the arrival airport', async () => {
render(<DayPlanSidebar {...makeDefaultProps({
days: movingDays, accommodations: stays, reservations: [flight(true)], selectedDayId: 11, routeShown: true,
})} />)
await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalledTimes(2))
const pairs = vi.mocked(calculateRouteWithLegs).mock.calls.map(c => c[0])
expect(pairs).toContainEqual([{ lat: 48.137, lng: 11.575 }, { lat: 48.353, lng: 11.786 }])
expect(pairs).toContainEqual([{ lat: 53.63, lng: 9.988 }, { lat: 53.551, lng: 9.993 }])
expect(pairs).not.toContainEqual([{ lat: 48.137, lng: 11.575 }, { lat: 53.551, lng: 9.993 }])
})
it('FE-PLANNER-DAYPLAN-224: a day with a single located stop still hands it over as a pin', async () => {
// Two stops, one of them without coordinates, and no stay: the route tools show,
// and the hand-offs open the one stop the way they always did.
const user = userEvent.setup()
const { generateGoogleMapsUrl } = await import('../Map/RouteCalculator')
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const day = buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' })
const assignments = {
'10': [
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Elbphilharmonie', lat: 53.541, lng: 9.984 }) }),
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'Somewhere nice', lat: null, lng: null }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10 })} />)
expect(screen.getByRole('button', { name: 'Open in CoMaps' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Open in Google Maps' }))
expect(vi.mocked(generateGoogleMapsUrl)).toHaveBeenCalledWith([{ lat: 53.541, lng: 9.984, name: 'Elbphilharmonie' }])
openSpy.mockRestore()
})
})
it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => {
const user = userEvent.setup()
const { daysApi } = await import('../../api/client')
@@ -4378,7 +4154,6 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, pushUndo, onReorder })} />)
await user.click(screen.getByRole('button', { name: 'Optimize' }))
await waitFor(() => expect(onReorder).toHaveBeenCalled())
expect(pushUndo.mock.calls[0][2]).toEqual([10])
const undo = pushUndo.mock.calls[0][1] as () => Promise<void>
await undo()
expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12, 13])
@@ -35,10 +35,10 @@ import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
getAssignmentReservations, timedSlot, storedRideSlot,
getAssignmentReservations,
type MergedItem,
} from '../../utils/dayMerge'
import { withinDriveRange } from '@trek/shared/roadtrip'
import { withinDriveRange } from '../../utils/geo'
import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates'
@@ -56,8 +56,6 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
@@ -78,23 +76,11 @@ interface DayPlanSidebarProps {
onReorder: (dayId: number, orderedIds: number[]) => void
onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void
/** The planner's add controls, for the second "Add with date" button on a trip with dates. */
dayAdd?: DayAddControls
/** Asks to delete a day from the reorder dialog; the planner owns the question. */
onDeleteDay?: (dayId: number) => void
/** The open delete question, which the reorder dialog asks in place of its list. */
deleteDayQuestion?: DayDeleteQuestion | null
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
onUpdateDayTitle: (dayId: number, title: string) => void
/** The day route is computed by the planner page itself; kept for the existing call sites. */
onRouteCalculated: (route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
/**
* Moves a stop over from another day, at a row index of that day or at its end, and
* keeps the vias drawn on that day on their legs. Without it the list moves the stop
* in the store and leaves the vias as they are.
*/
onMoveToDay?: (assignmentId: number, fromDayId: number, toDayId: number, position?: number) => Promise<void>
onRemoveAssignment: (dayId: number, assignmentId: number) => void
onEditPlace: (place: Place, assignmentId?: number) => void
onDeletePlace: (placeId: number) => void
@@ -116,8 +102,7 @@ interface DayPlanSidebarProps {
/** Open the place form already pointed at this day, to create a new place there. */
onCreatePlaceForDay?: (dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
/** `dayIds`: the days the step acts on, so deleting one of them drops it. */
pushUndo?: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[]) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean
lastActionLabel?: string | null
onUndo?: () => void
@@ -155,8 +140,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
onReorder, onReorderDays, onAddDay,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
visibleConnectionIds = [],
onToggleConnection,
@@ -203,9 +188,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
// Editing or deleting the place itself is a place right; taking it off the
// day stays a day right (#2446).
const canEditPlaces = can('place_edit', trip)
// The calendar subscription hands out a link that reads the trip without an
// account, so it sits behind the same permission as the public share link.
const canManageShare = can('share_manage', trip)
@@ -447,15 +429,7 @@ 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, dayId) => {
// A ride that lands today goes where it adds the least road between the same clocks,
// 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 computeTransportPosition = (r, da) => {
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
// Find the last place with time <= transport time
let afterIdx = -1
@@ -479,7 +453,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
)
const positions = sorted.map((r, idx) => ({
id: r.id,
day_plan_position: computeTransportPosition(r, da, dayId) + idx * 0.01,
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
}))
// Mark as initialized immediately to prevent re-entry
for (const p of positions) initedTransportIds.current.add(p.id)
@@ -533,19 +507,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return idx >= 0 ? idx : stored.length
}
// Moving a stop over from another day. One with a start is drawn by it wherever it
// is stored, and the road trip drives the stored order, so it is stored where the
// list will draw it rather than at the drop. One without a start makes the same call
// it always has, dropped where it was dropped or at the end of the day.
const moveToDay = (assignmentId: number, fromDayId: number, toDayId: number, dropAt?: number) => {
const moving = (assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
const slot = timedSlot(assignments[String(toDayId)] || [], accommodations, moving?.place?.place_time, dropAt) ?? dropAt
if (onMoveToDay) return onMoveToDay(assignmentId, fromDayId, toDayId, slot)
return slot === undefined
? tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId)
: tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, slot)
}
// The stop a drop on a note lands ahead of: the next place below the note.
const placeBelowNote = (dayId: number, noteId: number): Assignment | undefined => {
const tm = getMergedItems(dayId)
@@ -902,7 +863,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const capturedPrevIds = prevAssignmentIds
pushUndo?.(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
}, [capturedDayId])
})
}
} catch (err: unknown) {
rollBackReservations()
@@ -1077,7 +1038,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const capturedDayId = dayId
pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
}, [capturedDayId])
})
}
@@ -1097,11 +1058,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId))
const capturedFromDayId = fromDayId
const capturedOrderIndex = srcAssignment?.order_index ?? 0
moveToDay(Number(assignmentId), fromDayId, dayId)
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId)
.then(() => {
pushUndo?.(t('undo.moveDay'), async () => {
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
}, [Number(dayId), Number(capturedFromDayId)])
})
})
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId && fromDayId !== dayId) {
@@ -1138,9 +1099,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onReorder,
onReorderDays,
onAddDay,
dayAdd,
onDeleteDay,
deleteDayQuestion,
onAssignToDay,
onRemoveAssignment,
onEditPlace,
@@ -1190,7 +1148,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
tripActions,
can,
canEditDays,
canEditPlaces,
canManageShare,
noteUi,
setNoteUi,
@@ -1255,7 +1212,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
getMergedItems,
storedPositionBefore,
placeBelowNote,
moveToDay,
mergedItemsMap,
applyMergedOrder,
handleMergedDrop,
@@ -1343,9 +1299,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onReorder,
onReorderDays,
onAddDay,
dayAdd,
onDeleteDay,
deleteDayQuestion,
onAssignToDay,
onRemoveAssignment,
onEditPlace,
@@ -1395,7 +1348,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
tripActions,
can,
canEditDays,
canEditPlaces,
canManageShare,
noteUi,
setNoteUi,
@@ -1460,7 +1412,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
getMergedItems,
storedPositionBefore,
placeBelowNote,
moveToDay,
mergedItemsMap,
applyMergedOrder,
handleMergedDrop,
@@ -1605,9 +1556,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
canManageShare={canManageShare}
onReorderDays={onReorderDays}
onAddDay={onAddDay}
dayAdd={dayAdd}
onDeleteDay={onDeleteDay}
deleteDayQuestion={deleteDayQuestion}
/>
{/* Tagesliste */}
@@ -1645,20 +1593,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// useRouteCalculation) even with zero places. Mirror that exact gate here — two
// distinct bookend hotels you actually slept in / sleep in tonight — so the route
// tools appear when you click the day (#1297). A same-hotel rest day or a plain
// arrival/departure day has morning === evening and stays excluded. With a flight
// or train booked on it the map drops that leg (#2476): it keeps only the drives
// to and from the booking's located stations, and a booking without any leaves
// nothing to draw, so the tools stay away instead of sitting there dead.
// arrival/departure day has morning === evening and stays excluded.
const transferMorning = routeBookends?.morning
const transferEvening = routeBookends?.evening
const dayCarriers = (mergedItemsMap[day.id] || []).filter(i => i.type === 'transport' && isCarrierTransport(i.data))
const dayHasLocatedCarrier = dayCarriers.some(i => hasCarrierEndpointOnDay(i.data, day.id))
const hasHotelTransfer = !!(
routeBookends?.morningIsSleptHere && routeBookends?.eveningIsOvernight &&
transferMorning?.place_lat != null && transferMorning?.place_lng != null &&
transferEvening?.place_lat != null && transferEvening?.place_lng != null &&
(transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng) &&
(dayCarriers.length === 0 || dayHasLocatedCarrier)
(transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng)
)
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer
/**
@@ -1671,29 +1613,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
*/
const dayExportStops = (): NamedWaypoint[] => {
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
// A flight, train, ferry or coach on a day without stops is the move itself,
// located or not: the hotels at either end are joined by it, not by a road
// worth handing to a map app (#2476).
if (dayStops.length === 0 && dayCarriers.length > 0) return []
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined
const lastAssignment = dayStops[dayStops.length - 1]
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined
// Same carrier gate as the drawn route (#2157): the exported link must not
// start at a hotel you only reach tonight or lead back to one you left.
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasLocatedCarrier)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasLocatedCarrier)
const dayHasCarrier = (mergedItemsMap[day.id] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, day.id))
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasCarrier)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasCarrier)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
}
const showRouteTools = (isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable
// Built once, for the day the tools show on. With no stop to hand over the
// hand-offs would open nothing, so they are left out; a single stop still
// opens as a pin (#2476).
const exportStops = showRouteTools ? dayExportStops() : []
// Is this day's inline route currently on? Mobile toggles it per day (its
// own expandedRouteDayIds entry); desktop uses the global Route toggle on
// the selected day (#1374).
@@ -1937,7 +1871,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
} else if (assignmentId && fromDayId !== day.id) {
moveToDay(Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
} else if (noteId && fromDayId !== day.id) {
@@ -1960,7 +1894,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
setDropTargetKey(null); window.__dragData = null; return
}
if (assignmentId && fromDayId !== day.id) {
moveToDay(Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
@@ -2120,7 +2054,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
moveToDay(Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, assignment)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, assignment)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -2163,13 +2097,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// does not exist here.
const navTargets = getNavigationTargets(place)
ctxMenu.open(e, [
canEditPlaces && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}}
onMouseEnter={e => {
@@ -2544,7 +2478,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
} else if (fromAssignmentId && fromDayId !== day.id) {
moveToDay(Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
} else if (noteId && fromDayId !== day.id) {
@@ -2728,7 +2662,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
moveToDay(Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id))).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id))).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
@@ -2839,7 +2773,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
moveToDay(Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
@@ -2864,7 +2798,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend #1330 — oder Hotel-zu-Hotel-Transfertag ohne Orte #1297) */}
{showRouteTools && (
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button type="button"
@@ -2897,10 +2831,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{!narrowPanel && t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
{exportStops.length > 0 && <Tooltip label={t('planner.openGoogleMaps')} placement="top">
<Tooltip label={t('planner.openGoogleMaps')} placement="top">
<button type="button"
onClick={() => {
const url = generateGoogleMapsUrl(exportStops)
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
@@ -2913,14 +2847,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
>
<GoogleMapsIcon size={14} />
</button>
</Tooltip>}
</Tooltip>
{/* The same day, handed to CoMaps for offline navigation (#1904). The
day's own travel mode rides along, so the route it builds walks
when the plan walks. */}
{exportStops.length > 0 && <Tooltip label={t('planner.openCoMaps')} placement="top">
<Tooltip label={t('planner.openCoMaps')} placement="top">
<button type="button"
onClick={() => {
const url = generateCoMapsUrl(exportStops, day.default_transport_mode ?? routeProfile)
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
@@ -2933,7 +2867,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
>
<Compass size={14} strokeWidth={2} />
</button>
</Tooltip>}
</Tooltip>
{/* Icon-only, like the two map hand-offs beside it (#1981). It
was the one button here carrying a label with no room for
it: `flex: 1` alongside `padding: '6px 0'` meant the text
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
@@ -233,48 +233,6 @@ describe('DayPlanSidebarToolbar', () => {
expect(onAddDay).toHaveBeenCalledWith()
})
it('FE-PLANNER-DPTOOLBAR-024: the popup hands a delete request for its day to the planner', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
const onDeleteDay = vi.fn((_id: number) => {})
render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay })} />)
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
expect(onDeleteDay).toHaveBeenCalledWith(11)
})
it('FE-PLANNER-DPTOOLBAR-026: the planner question reaches the popup, which asks it in place of its list', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
const onCancel = vi.fn()
const question = { dayId: 11, title: 'Delete Day 2?', lines: [], onCancel, onConfirm: vi.fn() }
const props = makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay: vi.fn() })
const { rerender } = render(<DayPlanSidebarToolbar {...props} />)
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
rerender(<DayPlanSidebarToolbar {...props} deleteDayQuestion={question} />)
expect(screen.getByRole('region', { name: 'Delete Day 2?' })).toBeInTheDocument()
expect(screen.queryByText('dayplan.reorderHint')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(onCancel).toHaveBeenCalled()
})
it('FE-PLANNER-DPTOOLBAR-025: the popup gets the planner add controls, and its dated button adds the next date', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })]
const onAddDated = vi.fn()
const onAddDay = vi.fn()
render(<DayPlanSidebarToolbar {...makeProps({
days, canEditDays: true, onReorderDays: vi.fn(), onAddDay,
dayAdd: { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated },
})} />)
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
await user.click(screen.getByRole('button', { name: /^dayplan\.addDatedDay/ }))
expect(onAddDated).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
// The popup asks for an append, never a position.
expect(onAddDay).toHaveBeenCalledWith()
})
it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => {
const days = [buildDay({ id: 10 })]
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
@@ -5,8 +5,6 @@ import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { TripExportModal } from './TripExportModal'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
interface DayPlanSidebarToolbarProps {
@@ -39,12 +37,6 @@ interface DayPlanSidebarToolbarProps {
canManageShare?: boolean
onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void
/** The planner's add controls: on a trip with dates the dialog offers the next date as well. */
dayAdd?: DayAddControls
/** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */
onDeleteDay?: (dayId: number) => void
/** The open delete question; the dialog asks it in place of its day list. */
deleteDayQuestion?: DayDeleteQuestion | null
}
export function DayPlanSidebarToolbar({
@@ -52,7 +44,7 @@ export function DayPlanSidebarToolbar({
allConnectionsShown = false, onToggleAllConnections,
t, locale, toast,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
canEditDays, canManageShare = true, onReorderDays, onAddDay,
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
@@ -205,9 +197,6 @@ export function DayPlanSidebarToolbar({
locale={locale}
onReorder={onReorderDays}
onAddDay={() => onAddDay()}
dayAdd={dayAdd}
onDeleteDay={onDeleteDay}
deleteQuestion={deleteDayQuestion}
onClose={() => setReorderOpen(false)}
/>
</div>
@@ -1,12 +1,8 @@
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-028
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { MapPin } from 'lucide-react'
import { buildDay } from '../../../tests/helpers/factories'
import { DayReorderPopup } from './DayReorderPopup'
import { setForcedOffline } from '../../sync/networkMode'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Day } from '../../types'
// The component takes `t` as a prop, so returning the key keeps assertions exact.
@@ -125,9 +121,9 @@ describe('DayReorderPopup', () => {
fireEvent.dragStart(first)
fireEvent.dragOver(second)
// The hovered row is highlighted while a drag is in flight.
expect(second).toHaveClass('outline-dashed')
expect(second.style.outline).toContain('dashed')
fireEvent.dragEnd(first)
expect(second).not.toHaveClass('outline-dashed')
expect(second.style.outline).toBe('none')
expect(onReorder).not.toHaveBeenCalled()
})
@@ -141,225 +137,4 @@ describe('DayReorderPopup', () => {
await user.click(screen.getByText('common.close'))
expect(onClose).toHaveBeenCalled()
})
describe('deleting a day', () => {
afterEach(() => setForcedOffline(false))
it('FE-PLANNER-DAYREORDER-013: each row asks to delete its own day, and only asks', async () => {
const user = userEvent.setup()
const onDeleteDay = vi.fn()
const onReorder = vi.fn()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay, onReorder })} />)
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
expect(buttons).toHaveLength(3)
await user.click(buttons[1])
expect(onDeleteDay).toHaveBeenCalledWith(7)
expect(onReorder).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-014: without a delete handler the rows carry no delete button', () => {
render(<DayReorderPopup {...makeProps({ days: threeDays() })} />)
expect(screen.queryByRole('button', { name: 'dayplan.deleteDay' })).not.toBeInTheDocument()
// The label is still the row's second span, next to its position badge.
expect(rows()[0].querySelectorAll('span')[1].textContent).toBe('Paris')
})
it('FE-PLANNER-DAYREORDER-015: the only day of a trip cannot be deleted', async () => {
const user = userEvent.setup()
const onDeleteDay = vi.fn()
render(<DayReorderPopup {...makeProps({ days: [buildDay({ id: 3, title: 'Paris', day_number: 1 })], onDeleteDay })} />)
const button = screen.getByRole('button', { name: 'dayplan.deleteDay' })
expect(button).toBeDisabled()
await user.click(button)
expect(onDeleteDay).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-016: offline every delete button is off, and hovering says why', async () => {
setForcedOffline(true)
const user = userEvent.setup()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn() })} />)
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true)
await user.hover(buttons[0].parentElement as HTMLElement)
expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument()
})
})
describe('adding a day', () => {
const tWithDate = (key: string, params?: Record<string, unknown>) => (params?.date ? `${key}|${params.date}` : key)
const controls = (overrides: Partial<DayAddControls> = {}): DayAddControls => ({
nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides,
})
it('FE-PLANNER-DAYREORDER-017: a trip with dates offers a day without a date and the next date, by name', () => {
render(<DayReorderPopup {...makeProps({ days: threeDays(), t: tWithDate, dayAdd: controls() })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
expect(screen.getByRole('button', { name: /^dayplan\.addDatedDay\|.*Oct 13/ })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'dayplan.addDay' })).not.toBeInTheDocument()
expect(screen.getByText(/^dayplan\.addDatedDayHint\|.*Oct 13/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-018: each button adds its own kind of day', async () => {
const user = userEvent.setup()
const onAddDay = vi.fn()
const dayAdd = controls()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd })} />)
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
expect(onAddDay).toHaveBeenCalledTimes(1)
expect(dayAdd.onAddDated).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'dayplan.addDatedDay' }))
expect(dayAdd.onAddDated).toHaveBeenCalledTimes(1)
expect(onAddDay).toHaveBeenCalledTimes(1)
})
it('FE-PLANNER-DAYREORDER-019: a trip without dates keeps the single button, which waits for the connection', async () => {
const user = userEvent.setup()
const onAddDay = vi.fn()
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null }) })} />)
expect(screen.queryByRole('button', { name: 'dayplan.addDatedDay' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dayplan.addDay' }))
expect(onAddDay).toHaveBeenCalledTimes(1)
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null, blocked: 'dayplan.daysOffline' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addDay' })).toBeDisabled()
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-020: busy or offline turns both off, a trip at the day limit only the dated one, and the line says why', () => {
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ busy: true }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ blocked: 'dayplan.daysOffline' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ datedBlocked: 'dashboard.tripTooLong' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
expect(screen.getByText('dashboard.tripTooLong')).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-021: the line under the buttons follows the pointer and the focus onto the undated one', async () => {
const user = userEvent.setup()
render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls() })} />)
const undated = screen.getByRole('button', { name: 'dayplan.addUndatedDay' })
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
await user.hover(undated)
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
await user.unhover(undated)
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
fireEvent.focus(undated)
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
fireEvent.blur(undated)
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
// Both buttons are described by that line.
const hintId = screen.getByText('dayplan.addDatedDayHint').id
expect(undated).toHaveAttribute('aria-describedby', hintId)
})
})
describe('the delete question, asked in place of the list', () => {
const question = (overrides: Partial<DayDeleteQuestion> = {}): DayDeleteQuestion => ({
dayId: 7,
title: 'Delete Lyon?',
lines: [{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 2', hint: 'They stay in the place list.' }],
onCancel: vi.fn(),
onConfirm: vi.fn(),
...overrides,
})
it('FE-PLANNER-DAYREORDER-022: the question takes the place of the rows and the add buttons, in the same dialog', () => {
const dayAdd: DayAddControls = { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn() }
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: question(), dayAdd })} />)
expect(rows()).toHaveLength(0)
expect(screen.queryByRole('button', { name: 'dayplan.addUndatedDay' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.close' })).not.toBeInTheDocument()
// Still the reorder dialog: its own title stays, the question sits under it.
expect(screen.getByText('dayplan.reorderTitle')).toBeInTheDocument()
const section = screen.getByRole('region', { name: 'Delete Lyon?' })
expect(section).toHaveAccessibleDescription('dayplan.deleteDayBody')
expect(screen.getByRole('list', { name: 'Delete Lyon?' })).toHaveTextContent('Planned places: 2')
expect(screen.getByRole('button', { name: 'common.cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dayplan.deleteDay' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-023: Cancel takes the question back, the delete button answers it', async () => {
const user = userEvent.setup()
const q = question()
const onClose = vi.fn()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(q.onCancel).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'dayplan.deleteDay' }))
expect(q.onConfirm).toHaveBeenCalledTimes(1)
})
it('FE-PLANNER-DAYREORDER-024: Escape takes back only the question, not the dialog', async () => {
const user = userEvent.setup()
const q = question()
const onClose = vi.fn()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
await user.keyboard('{Escape}')
expect(q.onCancel).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-025: closing the dialog also drops an open question', async () => {
const user = userEvent.setup()
const q = question()
const onClose = vi.fn()
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
const closeX = document.querySelector<HTMLButtonElement>('h2 + button')
expect(closeX).not.toBeNull()
await user.click(closeX as HTMLButtonElement)
expect(q.onCancel).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
// A dialog closed from outside takes its question with it as well.
const again = question()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: again, onClose })} />)
rerender(<DayReorderPopup {...makeProps({ isOpen: false, days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: again, onClose })} />)
expect(again.onCancel).toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-026: a question about a day that is no longer there is not asked', () => {
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: question({ dayId: 99 }) })} />)
expect(rows()).toHaveLength(3)
expect(screen.queryByRole('region', { name: 'Delete Lyon?' })).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-027: the focus goes to Cancel, and back to the row asked about when the list returns', async () => {
const user = userEvent.setup()
const days = threeDays()
const onDeleteDay = vi.fn()
const { rerender } = render(<DayReorderPopup {...makeProps({ days, onDeleteDay })} />)
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
expect(onDeleteDay).toHaveBeenCalledWith(7)
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay, deleteQuestion: question() })} />)
expect(screen.getByRole('button', { name: 'common.cancel' })).toHaveFocus()
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay, deleteQuestion: null })} />)
expect(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]).toHaveFocus()
})
it('FE-PLANNER-DAYREORDER-028: after the day went, the focus lands on the row that took its place', async () => {
const user = userEvent.setup()
const days = threeDays()
const { rerender } = render(<DayReorderPopup {...makeProps({ days, onDeleteDay: vi.fn() })} />)
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay: vi.fn(), deleteQuestion: question() })} />)
const left = days.filter(d => d.id !== 7)
rerender(<DayReorderPopup {...makeProps({ days: left, onDeleteDay: vi.fn(), deleteQuestion: null })} />)
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
expect(buttons).toHaveLength(2)
expect(buttons[1]).toHaveFocus()
})
})
})
+110 -203
View File
@@ -1,14 +1,6 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { GripVertical, ArrowUp, ArrowDown, Trash2, AlertTriangle } from 'lucide-react'
import { useState } from 'react'
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import Tooltip from '../shared/Tooltip'
import DayImpactList from '../shared/DayImpactList'
import { DayAddFooter, FOOTER_BTN, SECONDARY_BTN } from './DayAddFooter'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { dayLabel } from '../../utils/dayLabel'
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Day } from '../../types'
interface DayReorderPopupProps {
@@ -18,56 +10,29 @@ interface DayReorderPopupProps {
locale: string
onReorder: (orderedIds: number[]) => void
onAddDay: () => void
/**
* The planner's add controls. On a trip with dates they add a second button for
* the next calendar day; without them the footer keeps its single "Add day".
*/
dayAdd?: DayAddControls
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
onDeleteDay?: (dayId: number) => void
/** The open delete question, asked in place of the day list until it is answered. */
deleteQuestion?: DayDeleteQuestion | null
onClose: () => void
}
/** Where the list stood when the question opened, to come back to it. */
interface ListSpot {
dayId: number
index: number
scrollTop: number
}
const ICON_BTN =
'grid h-7 w-7 flex-shrink-0 place-items-center rounded-md border border-edge-faint text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted'
const DELETE_BTN =
'grid h-7 w-7 place-items-center rounded-md text-content-faint transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-content-faint'
/**
* Modal for moving whole days around: drag a row by its grip or use the up/down
* arrows, add a day, or delete one. Day headers stay untouched, so this is the
* arrows, and add a day at the end. Day headers stay untouched — this is the
* single surface for ordering. Reorders are applied optimistically by the store,
* so the list reflects each move immediately.
*
* A delete only asks, and it asks right here: the dialog swaps its list for the
* question, what goes with the day and the two ways out, at the same width. The
* dialog hangs from a fixed top edge, so the swap only moves its bottom edge,
* and the question takes the height it needs. Cancel and Escape bring the list
* back where it was.
*/
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, deleteQuestion, onClose }: DayReorderPopupProps) {
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null)
const { offline } = useNetworkMode()
const bodyRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
const spot = useRef<ListSpot | null>(null)
const titleId = useId()
const bodyId = useId()
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t)
// A question about a day that is gone (deleted elsewhere) is no question.
const asking = isOpen && deleteQuestion && ordered.some(d => d.id === deleteQuestion.dayId) ? deleteQuestion : null
const label = (day: Day, index: number) => {
if (day.title) return day.title
if (day.date) {
const d = new Date(day.date + 'T00:00:00')
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
return t('dayplan.dayN', { n: index + 1 })
}
const move = (from: number, to: number) => {
if (to < 0 || to >= ordered.length || from === to) return
@@ -77,164 +42,106 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
onReorder(ids)
}
const ask = (dayId: number, index: number) => {
spot.current = { dayId, index, scrollTop: bodyRef.current?.parentElement?.scrollTop ?? 0 }
onDeleteDay?.(dayId)
}
// Closing the dialog also drops a question left open in it; so does the
// dialog going away with the sidebar that holds it.
const questionRef = useRef(deleteQuestion)
questionRef.current = deleteQuestion
const close = () => { questionRef.current?.onCancel(); onClose() }
useEffect(() => { if (!isOpen) questionRef.current?.onCancel() }, [isOpen])
useEffect(() => () => questionRef.current?.onCancel(), [])
// Escape takes back the question, not the dialog. Captured and stopped
// before the dialog's own listener, which would close everything.
useEffect(() => {
if (!asking) return
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
asking.onCancel()
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [asking])
// The question opens at its top with the focus on Cancel. Back at the list,
// the scroll position and the focus return to the row asked about.
const askedDayId = asking?.dayId ?? null
const shownDayId = useRef<number | null>(null)
useLayoutEffect(() => {
const body = bodyRef.current
// Closed with the question open: the next opening starts at the list, fresh.
if (!body) { shownDayId.current = null; spot.current = null; return }
if (shownDayId.current === askedDayId) return
shownDayId.current = askedDayId
const scroller = body.parentElement
if (askedDayId != null) {
if (scroller) scroller.scrollTop = 0
cancelRef.current?.focus()
return
}
const from = spot.current
if (!from) return
spot.current = null
if (scroller) scroller.scrollTop = from.scrollTop
const buttons = body.querySelectorAll<HTMLButtonElement>('[data-delete-day]')
const again = body.querySelector<HTMLButtonElement>(`[data-delete-day="${from.dayId}"]`)
?? buttons[Math.min(from.index, buttons.length - 1)]
again?.focus()
}, [askedDayId])
const footer = asking ? (
<div className="flex items-center justify-end gap-2">
<button ref={cancelRef} type="button" onClick={asking.onCancel} className={SECONDARY_BTN}>
{t('common.cancel')}
</button>
<button type="button" onClick={asking.onConfirm} className={`${FOOTER_BTN} bg-danger font-semibold text-white hover:opacity-90`}>
<Trash2 size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.deleteDay')}
</button>
</div>
) : (
<DayAddFooter dayAdd={dayAdd} onAddDay={onAddDay} onClose={close} t={t} locale={locale} />
)
const cellBtn = {
display: 'grid', placeItems: 'center', width: 28, height: 28,
border: '1px solid var(--border-faint)', borderRadius: 7,
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
} as const
return (
<Modal isOpen={isOpen} onClose={close} title={t('dayplan.reorderTitle')} size="lg" align="top" footer={footer}>
<div ref={bodyRef}>
{asking ? (
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
<div className="flex items-start gap-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-danger-soft text-danger">
<AlertTriangle size={16} strokeWidth={2} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h3 id={titleId} className="text-subtitle font-semibold leading-snug text-content">{asking.title}</h3>
<p id={bodyId} className="mt-0.5 text-caption text-content-muted">{t('dayplan.deleteDayBody')}</p>
</div>
</div>
<DayImpactList lines={asking.lines} label={asking.title} />
</section>
) : (
<>
<p className="mb-3 text-caption text-content-muted">{t('dayplan.reorderHint')}</p>
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('dayplan.reorderTitle')}
size="md"
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<button type="button"
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.close')}
</button>
<button type="button"
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={15} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
}
>
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
{t('dayplan.reorderHint')}
</p>
{/* The popup is a modal, so it portals out of the planner and has to opt
into the long-press drag itself (#1616). Without this a finger only
selects the row text. */}
<div data-touch-drag className="flex flex-col gap-1">
{ordered.map((day, index) => {
const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index
return (
<div
key={day.id}
draggable
onDragStart={() => setDragIndex(index)}
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
onDrop={e => {
e.preventDefault()
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
setDragIndex(null); setOverIndex(null)
}}
className={`flex items-center gap-2.5 rounded-lg border border-edge-faint px-2.5 py-1.5 ${
dropTarget ? 'bg-surface-hover outline-dashed outline-2 -outline-offset-2 outline-edge' : 'bg-surface-card'
} ${dragIndex === index ? 'opacity-50' : ''}`}
>
<GripVertical size={15} strokeWidth={1.8} className="flex-shrink-0 cursor-grab text-content-faint" />
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-full bg-surface-hover text-caption font-semibold text-content-muted">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate text-body font-medium text-content">
{dayLabel(day, index, t, locale)}
</span>
<button
type="button"
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
className={ICON_BTN}
>
<ArrowUp size={14} strokeWidth={2} />
</button>
<button
type="button"
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
className={ICON_BTN}
>
<ArrowDown size={14} strokeWidth={2} />
</button>
{onDeleteDay && (
<>
<span aria-hidden="true" className="h-5 w-px flex-shrink-0 bg-edge-faint" />
<Tooltip label={deleteBlocked ?? t('dayplan.deleteDay')} placement="left">
<span className="inline-flex flex-shrink-0">
<button
type="button"
data-delete-day={day.id}
onClick={() => ask(day.id, index)}
disabled={!!deleteBlocked}
aria-label={t('dayplan.deleteDay')}
className={DELETE_BTN}
>
<Trash2 size={15} strokeWidth={2} />
</button>
</span>
</Tooltip>
</>
)}
</div>
)
})}
</div>
</>
)}
{/* The popup is a modal, so it portals out of the planner and has to opt
into the long-press drag itself (#1616). Without this a finger only
selects the row text. */}
<div data-touch-drag style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{ordered.map((day, index) => (
<div
key={day.id}
draggable
onDragStart={() => setDragIndex(index)}
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
onDrop={e => {
e.preventDefault()
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
setDragIndex(null); setOverIndex(null)
}}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
borderRadius: 9,
border: '1px solid var(--border-faint)',
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
opacity: dragIndex === index ? 0.5 : 1,
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
outlineOffset: -2,
}}
>
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label(day, index)}
</span>
<button
type="button"
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
>
<ArrowUp size={14} strokeWidth={2} />
</button>
<button
type="button"
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
>
<ArrowDown size={14} strokeWidth={2} />
</button>
</div>
))}
</div>
</Modal>
)
@@ -1,4 +1,4 @@
// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-024
// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-019
import { useState } from 'react';
import { delay, http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
@@ -29,15 +29,11 @@ function searchRoute(places: SearchHit[] | (() => Response | Promise<Response>))
// Controlled host — `value` drives the input text and mutes the search for the
// already-picked name, exactly like ReservationModal wires it up.
function Host({ initial = null, onPick, places }: { initial?: LocationPoint | null; onPick?: (l: LocationPoint | null) => void; places?: LocationPoint[] }) {
function Host({ initial = null, onPick }: { initial?: LocationPoint | null; onPick?: (l: LocationPoint | null) => void }) {
const [value, setValue] = useState<LocationPoint | null>(initial);
return <LocationSelect value={value} onChange={(l) => { setValue(l); onPick?.(l); }} places={places} />;
return <LocationSelect value={value} onChange={(l) => { setValue(l); onPick?.(l); }} />;
}
/** The trip's own places, as the transport forms hand them over (#2468). */
const HOTEL = { name: 'Bach Suites Saigon', lat: 10.78, lng: 106.7, address: 'District 1' };
const EXPO = { name: 'Textile Exhibition', lat: 10.8, lng: 106.66, address: null };
describe('LocationSelect', () => {
it('FE-PLANNER-LOCSEL-001: falls back to the translated placeholder', () => {
render(<LocationSelect value={null} onChange={vi.fn()} />);
@@ -267,77 +263,4 @@ describe('LocationSelect', () => {
await waitFor(() => expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument());
});
it('FE-PLANNER-LOCSEL-020: an empty field offers the trip places, and picking one needs no search', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(searchRoute(() => { calls++; return HttpResponse.json({ places: [GARE] }); }));
const onPick = vi.fn();
render(<Host places={[HOTEL, EXPO]} onPick={onPick} />);
await user.click(screen.getByRole('textbox'));
expect(screen.getByText('Bach Suites Saigon')).toBeInTheDocument();
expect(screen.getByText('Textile Exhibition')).toBeInTheDocument();
await user.click(screen.getByText('Textile Exhibition'));
expect(onPick).toHaveBeenLastCalledWith({ name: 'Textile Exhibition', lat: 10.8, lng: 106.66, address: null });
await settle();
expect(calls).toBe(0);
});
it('FE-PLANNER-LOCSEL-021: one or two typed characters narrow the trip places by name', async () => {
const user = userEvent.setup();
render(<Host places={[HOTEL, EXPO]} />);
await user.type(screen.getByRole('textbox'), 'te');
expect(screen.getByText('Textile Exhibition')).toBeInTheDocument();
// "Bach Suites" has a "te" inside "Suites", so it stays; a name without it goes.
expect(screen.getByText('Bach Suites Saigon')).toBeInTheDocument();
await user.type(screen.getByRole('textbox'), '{backspace}{backspace}ex');
expect(screen.getByText('Textile Exhibition')).toBeInTheDocument();
expect(screen.queryByText('Bach Suites Saigon')).not.toBeInTheDocument();
});
it('FE-PLANNER-LOCSEL-022: from the third character on the map search answers instead of the trip places', async () => {
const user = userEvent.setup();
server.use(searchRoute([GARE]));
render(<Host places={[HOTEL, EXPO]} />);
await user.type(screen.getByRole('textbox'), 'Gare');
expect(await screen.findByText('Gare du Nord')).toBeInTheDocument();
expect(screen.queryByText('Bach Suites Saigon')).not.toBeInTheDocument();
});
it('FE-PLANNER-LOCSEL-023: a place already picked does not reopen the trip places when the field is focused', async () => {
const user = userEvent.setup();
render(<Host places={[HOTEL, EXPO]} initial={{ name: 'Ab', lat: 1, lng: 2 }} />);
await user.click(screen.getByRole('textbox'));
expect(screen.queryByText('Bach Suites Saigon')).not.toBeInTheDocument();
});
it('FE-PLANNER-LOCSEL-024: shortening the query after moving down the search results picks nothing stale', async () => {
const user = userEvent.setup();
server.use(searchRoute([GARE, LYON, { ...GARE, name: 'Gare de l Est', osm_id: 'n3' }]));
const onPick = vi.fn();
render(<Host places={[HOTEL, EXPO]} onPick={onPick} />);
const input = screen.getByRole('textbox');
await user.type(input, 'Gare');
await screen.findByText('Gare de Lyon');
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}');
// Back under three characters: the trip places are shown and nothing is highlighted,
// so Enter does nothing rather than reaching for the third row of the old list.
await user.type(input, '{backspace}{backspace}{backspace}');
await user.keyboard('{Enter}');
expect(onPick).not.toHaveBeenCalledWith(expect.objectContaining({ lat: expect.any(Number) }));
await user.keyboard('{ArrowDown}{Enter}');
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Bach Suites Saigon' }));
});
});
@@ -16,15 +16,9 @@ interface Props {
onChange: (loc: LocationPoint | null) => void
placeholder?: string
style?: React.CSSProperties
/**
* The trip's own places, offered while the field is empty or holds fewer than three
* characters, the way the transit search offers them (#2468). Typing narrows them by
* name; from the third character on the map search answers instead.
*/
places?: LocationPoint[]
}
export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) {
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
@@ -87,20 +81,11 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
setResults([])
}
// The trip's places stand in for the search below three characters. Derived rather
// than written into `results`, so a search answer that lands after the field was
// shortened cannot replace them, and a place already picked does not reopen them.
const typed = query.trim()
const showPicks = !value && typed.length < 3 && !!places?.length
const rows = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
: results
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || rows.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, rows.length - 1)) }
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
else if (e.key === 'Enter' && rows[highlight]) { e.preventDefault(); pick(rows[highlight]) }
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
else if (e.key === 'Escape') setOpen(false)
}
@@ -112,7 +97,7 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
type="text"
value={query}
placeholder={placeholder ?? t('reservations.searchLocation')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
className="bg-transparent text-content"
@@ -125,14 +110,14 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
)}
</div>
{open && ((loading && !showPicks) || rows.length > 0) && (
{open && (loading || results.length > 0) && (
<div className="bg-surface-card" style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
{loading && !showPicks && rows.length === 0 && (
{loading && results.length === 0 && (
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div>
)}
{rows.map((r, i) => (
{results.map((r, i) => (
<button
key={showPicks ? `pick:${r.name}:${r.lat}:${r.lng}` : `${r.osm_id || r.google_place_id || i}`}
key={`${r.osm_id || r.google_place_id || i}`}
type="button"
onClick={() => pick(r)}
onMouseEnter={() => setHighlight(i)}
@@ -1,5 +1,5 @@
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -091
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -080
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -162,88 +162,6 @@ describe('PlaceFormModal', () => {
expect(screen.queryByDisplayValue('Old Place')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-088: closing the dialog clears the search field', async () => {
const user = userEvent.setup();
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel Tower');
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.getByPlaceholderText('Search places...')).toHaveValue('');
});
it('FE-PLANNER-PLACEFORM-089: closing the dialog drops the result list and its Google line', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Search Google instead/ })).toBeInTheDocument();
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.getByPlaceholderText('Search places...')).toHaveValue('');
expect(screen.queryByText('Weigh station')).toBeNull();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-090: suggestions still on their way when the dialog closes never open over the next one', async () => {
const user = userEvent.setup();
const gate: { started: boolean; release?: () => void } = { started: false };
server.use(
http.post('/api/maps/autocomplete', async () => {
gate.started = true;
await new Promise<void>(res => { gate.release = res; });
return HttpResponse.json({ suggestions: [{ placeId: 'late-1', mainText: 'Late Suggestion', secondaryText: 'Nowhere' }], source: 'trek-places' });
}),
);
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Late');
await waitFor(() => expect(gate.started).toBe(true));
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
gate.release?.();
await act(async () => { await new Promise(res => setTimeout(res, 100)); });
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.queryByText('Late Suggestion')).toBeNull();
});
it('FE-PLANNER-PLACEFORM-091: a search that answers after the dialog closed leaves the next opening empty', async () => {
const user = userEvent.setup();
const gate: { started: boolean; release?: () => void } = { started: false };
server.use(
http.post('/api/maps/search', async () => {
gate.started = true;
await new Promise<void>(res => { gate.release = res; });
return HttpResponse.json({ places: [{ name: 'Late Result', address: 'Nowhere', lat: '1', lng: '2' }] });
}),
);
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Late');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
await waitFor(() => expect(gate.started).toBe(true));
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
gate.release?.();
await act(async () => { await new Promise(res => setTimeout(res, 100)); });
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.queryByText('Late Result')).toBeNull();
// The button is back to its icon rather than the "..." of a search in flight.
expect(within(screen.getByPlaceholderText('Search places...').closest('.flex') as HTMLElement).getByRole('button')).not.toHaveTextContent('...');
});
// ── Maps search ──────────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-018: maps search populates results via button click', async () => {
@@ -315,81 +233,6 @@ describe('PlaceFormModal', () => {
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018d: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => {
// The server only honours the request while Google holds the keyed slot;
// under another provider the link would re-run the same search and stay.
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018e: the link sends the query the list came from, not what the field holds by then', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })),
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// The list stays while the field is retyped; the line still means this list.
await user.clear(searchInput);
await user.type(searchInput, 'Kyoto');
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies).toHaveLength(2);
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-018f: the link still works after the field was cleared', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// An empty field used to make the click a silent no-op.
await user.clear(searchInput);
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
const user = userEvent.setup();
server.use(
@@ -18,7 +18,7 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -191,17 +191,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
// Counts the closings of this dialog, so an answer still on its way when it
// closed can tell that the opening it was asked for is over. A counter rather
// than an abort, because the full search takes no signal.
const searchEpochRef = useRef(0)
// Ties one search's keystrokes and its details lookup into a single Google
// billing session (see utils/placesSession).
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
@@ -322,31 +317,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place, prefillCoords, isOpen, assignmentId])
// The planner keeps this dialog mounted while it is closed (Modal only stops
// drawing it), so everything the search block holds would greet the next
// opening: the last query, its list, the Google line offering that list's
// query again, suggestions still on their way. A pick from that list writes
// over the place being edited, whichever place that is by then. Reset when
// it closes, in an effect of its own: the one above also reruns while the
// dialog is open, when a right-click's reverse lookup fills prefillCoords,
// and must not wipe a search the user has started since.
useEffect(() => {
if (isOpen) return
searchEpochRef.current += 1
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
acAbortRef.current?.abort()
placesSessionRef.current.end()
searchMetaRef.current = null
acMetaRef.current = null
setMapsSearch('')
setMapsResults([])
setSearchSource('')
setAcSuggestions([])
setAcSource('')
setAcHighlight(-1)
setIsSearchingMaps(false)
}, [isOpen])
useEffect(() => {
if (isOpen) {
setTimeout(() => {
@@ -427,18 +397,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
const handleMapsSearch = async (provider?: 'google') => {
// The retry sends the query the list came from, not the field: the list
// stays on screen while the field is edited or cleared, and the line under
// it promises the same query.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
if (!trimmed) return
const epoch = searchEpochRef.current
if (!mapsSearch.trim()) return
setIsSearchingMaps(true)
try {
// A pasted Google Maps or Amap link resolves server-side into a place
if (!provider && isMapUrl(trimmed)) {
const trimmed = mapsSearch.trim()
if (isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (epoch !== searchEpochRef.current) return
if (resolved.lat && resolved.lng) {
setForm(prev => ({
...prev,
@@ -454,16 +419,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return
}
}
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
if (epoch !== searchEpochRef.current) return
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
if (epoch !== searchEpochRef.current) return
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
if (epoch === searchEpochRef.current) setIsSearchingMaps(false)
setIsSearchingMaps(false)
}
}
@@ -523,7 +486,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setAcSuggestions([])
setAcHighlight(-1)
const previousSearch = mapsSearch
const epoch = searchEpochRef.current
setMapsSearch('')
setForm(prev => ({ ...prev, name: suggestion.mainText }))
setIsSearchingMaps(true)
@@ -545,10 +507,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} catch (err) {
console.error('Failed to fetch place details:', err)
}
// Closed while the details were on their way: the pick belongs to an
// opening that is over, and the fallback search below is not worth a
// request nobody will see.
if (epoch !== searchEpochRef.current) return
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's rows carry no address; their second line is the name
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
@@ -567,7 +525,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language, locationBiasPoint)
if (epoch !== searchEpochRef.current) return
place = search.places?.[0] ?? null
}
if (place) {
@@ -577,17 +534,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
toast.error(t('places.mapsSearchError'))
}
} catch (err) {
if (epoch !== searchEpochRef.current) return
console.error('Place suggestion lookup failed:', err)
setMapsSearch(previousSearch)
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
// Closing already ended this session; ending it again here would cut off
// the one a new opening may have started meanwhile.
if (epoch === searchEpochRef.current) {
setIsSearchingMaps(false)
placesSessionRef.current.end()
}
setIsSearchingMaps(false)
placesSessionRef.current.end()
}
}
@@ -808,7 +760,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language,
locale,
timeFormat,
googleAnswers,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
@@ -886,7 +838,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast,
t,
language,
googleAnswers,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
@@ -1053,9 +1005,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
{/* The index answers first and Google only when it finds nothing, so a
list with the wrong place on it never reaches Google by itself. One
quiet line under the list sends the same query there, on an instance
where Google holds the key slot and for a list Google did not
already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
that has a key and for a list Google did not already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
<button
type="button"
onClick={() => handleMapsSearch('google')}
@@ -1196,8 +1147,9 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
options={[
{ value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({
// A string like form.category_id, so the picked option hands
// back the same kind of value the form already keeps.
// form.category_id is a string; CustomSelect matches options by
// strict equality, so the option value must be a string too —
// otherwise the chosen category never renders in the trigger.
value: String(c.id),
label: c.name,
})),
@@ -8,11 +8,9 @@ import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { usePluginStore } from '../../store/pluginStore';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import type { AssignmentsMap } from '../../types';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
// ── Module mocks ──────────────────────────────────────────────────────────────
@@ -1480,25 +1478,6 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
});
it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', () => {
// buildUser() is a plain member and buildTrip({ id: 1 }) belongs to somebody else.
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
const onEdit = vi.fn();
const onDelete = vi.fn();
const onUpdatePlace = vi.fn();
const member = render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
fireEvent.doubleClick(screen.getByText(place.name));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
member.unmount();
// the same trip owned by this member shows them again
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) });
render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => {
// Taking it off the day would leave the booking behind with nothing on the drive and
@@ -1566,25 +1545,3 @@ describe('PlaceInspector', () => {
expect(screen.queryByText('Remove from Day')).toBeNull();
});
})
// ── Blur booking codes on the linked-booking strip (#2457) ────────────────────
describe('PlaceInspector blur booking codes (#2457)', () => {
const renderWithBooking = (blur: boolean) => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: blur } });
const res = { ...buildReservation({ id: 3, title: 'Dinner at Jules Verne', status: 'confirmed', confirmation_number: 'TABLE-SECRET' }), assignment_id: 9 };
render(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[res]} onEditReservation={vi.fn()} />);
};
it('FE-PLANNER-INSPECTOR-103: the booking code on the linked-booking strip is blurred while the setting is on', () => {
renderWithBooking(true);
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(true);
});
it('FE-PLANNER-INSPECTOR-104: with the setting off the strip shows the code plainly', () => {
renderWithBooking(false);
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(false);
});
});
@@ -10,7 +10,6 @@ import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
@@ -33,7 +32,6 @@ import type { Place, Category, Day, Assignment, Reservation, TripFile, Assignmen
import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
@@ -186,21 +184,11 @@ interface PlaceInspectorProps {
export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onRate,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
// Editing the place is a place right. The planner hands the handlers over
// regardless, and a member without the right saw Edit, Delete and the inline
// rename and got the server's 403 for each (#2446). Collection mode gates
// its own actions.
const can = useCanDo()
const trip = useTripStore(s => s.trip)
const mayEditPlace = mode !== 'trip' || can('place_edit', trip)
const onEdit = mayEditPlace ? editPlace : undefined
const onDelete = mayEditPlace ? deletePlace : undefined
const onUpdatePlace = mayEditPlace ? updatePlace : undefined
// Plugins that declared a place-detail slot mount at the bottom of this panel,
// scoped to the open place (trip mode only). Inline-filter like the other sites.
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
@@ -961,7 +949,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
{res.confirmation_number && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}><BlurredCode>{res.confirmation_number}</BlurredCode></div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
@@ -196,9 +196,9 @@ export function PlacesHeader(S: SidebarState) {
{/* Says out loud what the count above already narrowed to.
The map has followed the open day on this filter since #2024, and until now
nothing anywhere said so: the pool read 55, the map drew five, and the honest
conclusion was that the map was broken. It sits under the filter select
rather than on the map because a chip over the canvas is unreachable on a
phone, which is where this was reported from. */}
conclusion was that the map was broken. It sits under the tabs rather than on
the map because a chip over the canvas is unreachable on a phone, which is
where this was reported from. */}
{dayScoped && (
<div
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
@@ -1,13 +1,11 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-102
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
@@ -627,29 +625,6 @@ describe('ReservationModal', () => {
);
});
it('FE-PLANNER-RESMODAL-101: an imported track is not offered as the place of a stay', async () => {
const hotel = buildPlace({ id: 21, name: 'Hotel Adler' });
const track = buildPlace({ id: 22, name: 'Rheinsteig', route_geometry: '[[50.1,7.6],[50.2,7.7]]' });
render(<ReservationModal {...defaultProps} places={[hotel, track]} />);
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
const field = screen.getAllByText('Accommodation').find(el => el.tagName === 'LABEL')!.parentElement!;
await userEvent.click(within(field).getByRole('button'));
expect(screen.getByText('Hotel Adler')).toBeInTheDocument();
expect(screen.queryByText('Rheinsteig')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-102: a stay already booked at a track still shows that track', () => {
const track = buildPlace({ id: 22, name: 'Rheinsteig', route_geometry: '[[50.1,7.6],[50.2,7.7]]' });
const days = [buildDay({ id: 1 }), buildDay({ id: 2 })];
const accommodations = [{ id: 7, trip_id: 1, place_id: 22, start_day_id: 1, end_day_id: 2 }];
const res = buildReservation({ id: 9, type: 'hotel', title: 'Hut', accommodation_id: 7 });
render(<ReservationModal {...defaultProps} days={days} places={[track]} accommodations={accommodations as never} reservation={res} />);
expect(screen.getByText('Rheinsteig')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-043: hover styles applied to file picker items', async () => {
const res = buildReservation({ id: 5 });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
@@ -1591,57 +1566,4 @@ describe('ReservationModal', () => {
expect(times[0].value).toBe('16:00');
expect(times[2].value).toBe('10:30');
});
// ── Blur booking codes in the edit form (#2457) ─────────────────────────────
describe('blur booking codes (#2457)', () => {
const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } });
it('FE-PLANNER-RESMODAL-096: the booking code field is blurred while the setting is on and the field is not focused', () => {
blurOn(true);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET');
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-RESMODAL-097: a hotel booking hides its code the same way', () => {
blurOn(true);
const res = buildReservation({ type: 'hotel', title: 'Hotel Adlon', confirmation_number: 'HOTEL-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('HOTEL-SECRET'))).toBe(true);
});
it('FE-PLANNER-RESMODAL-098: focusing the field reveals the code for editing, leaving it hides it again', () => {
blurOn(true);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET') as HTMLInputElement;
expect(isBlurred(code)).toBe(true);
act(() => code.focus());
expect(isBlurred(code)).toBe(false);
act(() => code.blur());
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-RESMODAL-099: with the setting off the code stays plain', () => {
blurOn(false);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-PLAIN' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('PNR-PLAIN'))).toBe(false);
});
it('FE-PLANNER-RESMODAL-100: a blurred code still saves unchanged, and an edit typed into it is kept', async () => {
blurOn(true);
const onSave = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ type: 'restaurant', title: 'Dinner', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET');
await userEvent.clear(code);
await userEvent.type(code, 'PNR-NEW');
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].confirmation_number).toBe('PNR-NEW');
});
});
});
@@ -6,7 +6,6 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import { buildAssignmentOptions } from './assignmentOptions'
import AddressInput from './AddressInput'
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react'
@@ -24,7 +23,6 @@ import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
import { stayPlaces } from '../../utils/stayPlaces'
const TYPE_OPTIONS = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
@@ -561,7 +559,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
<div>
@@ -602,7 +600,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.meta.pickHotel')}
options={[
{ value: '', label: '—' },
...stayPlaces(places, form.hotel_place_id).map(p => ({ value: p.id, label: p.name })),
...places.map(p => ({ value: p.id, label: p.name })),
]}
searchable
size="sm"
@@ -1,13 +1,11 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-070
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-064
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
@@ -41,13 +39,8 @@ vi.mock('./AirportSelect', () => ({
}));
vi.mock('./LocationSelect', () => ({
default: ({ onChange, places }: { onChange: (l: any) => void; places?: { name: string }[] }) => (
<input
data-testid="location-select"
data-picks={(places ?? []).map(p => p.name).join('|')}
type="text"
onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })}
/>
default: ({ onChange }: { onChange: (l: any) => void }) => (
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
),
}));
@@ -1364,108 +1357,4 @@ describe('TransportModal', () => {
expect(screen.getByText(/Orsay/)).toBeInTheDocument();
expect(screen.queryByText('Louvre')).not.toBeInTheDocument();
});
// ── Blur booking codes in the edit form (#2457) ─────────────────────────────
describe('blur booking codes (#2457)', () => {
const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } });
const codeFields = () => screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
function multiLegTrain(): Reservation {
const res = buildReservation({ id: 32, title: 'Osaka → Kyoto → Nara', type: 'train' });
return Object.assign(res, {
day_id: 10,
end_day_id: 10,
confirmation_number: 'RAIL-BOOK',
metadata: {
legs: [
{ from: 'Osaka', to: 'Kyoto', train_number: 'JR 1', confirmation_number: 'RAIL-LEG1', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '08:30' },
{ from: 'Kyoto', to: 'Nara', train_number: 'JR 2', confirmation_number: 'RAIL-LEG2', dep_day_id: 10, dep_time: '09:00', arr_day_id: 10, arr_time: '09:45' },
],
},
endpoints: [
{ id: 11, reservation_id: 32, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '08:00' },
{ id: 12, reservation_id: 32, role: 'stop', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:00' },
{ id: 13, reservation_id: 32, role: 'to', sequence: 2, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:45' },
],
}) as unknown as Reservation;
}
it('FE-PLANNER-TRANSMODAL-065: the flight booking code field is blurred while the setting is on', () => {
blurOn(true);
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('FLY-SECRET'))).toBe(true);
});
it('FE-PLANNER-TRANSMODAL-066: every per-segment code of a stopover flight is blurred as well', () => {
blurOn(true);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
const codes = codeFields();
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]);
});
it('FE-PLANNER-TRANSMODAL-067: every per-segment code of a multi-leg train is blurred as well', () => {
blurOn(true);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} />);
const codes = codeFields();
expect(codes.map(i => i.value)).toEqual(['RAIL-LEG1', 'RAIL-LEG2', 'RAIL-BOOK']);
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]);
});
it('FE-PLANNER-TRANSMODAL-068: focusing a code field reveals it for editing, leaving it hides it again', () => {
blurOn(true);
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
render(<TransportModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('FLY-SECRET') as HTMLInputElement;
expect(isBlurred(code)).toBe(true);
act(() => code.focus());
expect(isBlurred(code)).toBe(false);
act(() => code.blur());
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-TRANSMODAL-069: with the setting off every code field stays plain', () => {
blurOn(false);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]);
});
it('FE-PLANNER-TRANSMODAL-070: blurred codes still save unchanged', async () => {
blurOn(true);
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
expect(payload.confirmation_number).toBe('BOOK1');
});
});
it('FE-PLANNER-TRANSMODAL-071: the manual From and To fields offer the trip places that have a location, each once (#2468)', async () => {
const places = [
buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.33 }),
buildPlace({ id: 2, name: 'No pin yet', lat: null, lng: null }),
buildPlace({ id: 3, name: 'Louvre', lat: 48.86, lng: 2.33 }),
];
render(<TransportModal {...defaultProps} places={places} />);
await userEvent.click(screen.getByRole('button', { name: /^Bus$/i }));
const fields = screen.getAllByTestId('location-select');
expect(fields).toHaveLength(2);
for (const field of fields) expect(field).toHaveAttribute('data-picks', 'Louvre');
});
it('FE-PLANNER-TRANSMODAL-072: every train station field offers the trip places too', async () => {
const places = [buildPlace({ id: 1, name: 'Berlin Hbf', lat: 52.52, lng: 13.37 })];
render(<TransportModal {...defaultProps} places={places} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const stations = screen.getAllByTestId('location-select');
expect(stations).toHaveLength(3);
for (const station of stations) expect(station).toHaveAttribute('data-picks', 'Berlin Hbf');
});
});
@@ -1,13 +1,11 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import CustomTimePicker from '../shared/CustomTimePicker'
import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import { toLocationPicks } from './locationPicks'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
@@ -16,7 +14,7 @@ import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
@@ -57,6 +55,14 @@ function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', se
}
}
// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle". Done as a trim
// plus an anchored test instead of /\s*\([A-Z]{3}\)\s*$/, because the leading
// \s* backtracks over every space in a long name for a quadratic worst case.
function stripAirportCode(name: string): string {
const trimmed = name.trimEnd()
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return {
@@ -188,8 +194,6 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
// The trip's places, offered by every location field of the manual tab (#2468).
const locationPicks = useMemo(() => toLocationPicks(places), [places])
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
@@ -899,7 +903,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{writesFlightLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
@@ -932,7 +936,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} places={locationPicks} />
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} />
</div>
{!isFirst && !isLast && (
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
@@ -980,7 +984,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{writesTrainLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
@@ -1004,11 +1008,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.from')}</label>
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} places={locationPicks} />
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.to')}</label>
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} places={locationPicks} />
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
</div>
</div>
@@ -1052,7 +1056,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<LocationSelect
value={stop.location}
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
places={locationPicks}
/>
</div>
<div style={{ width: 110, flexShrink: 0 }}>
@@ -1116,7 +1119,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
<div>
@@ -96,8 +96,7 @@ describe('TripExportModal', () => {
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, showServiceStops: true,
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
})
@@ -6,7 +6,6 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import { useSettingsStore } from '../../store/settingsStore'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
/**
* What a GPX download can carry. Worded by what someone wants on their device
@@ -62,9 +61,6 @@ export function TripExportModal({
// The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
// The export gets the store's assignments and applies the day plan's own filter to
// them, so it needs the same switch the plan reads.
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId)
const fileBase = trip?.title || 'trip'
// Shared tail of every download: Firefox and Safari cancel the download when
@@ -91,7 +87,7 @@ export function TripExportModal({
// exported. A missing chunk lands in the catch and shows the same error
// the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops })
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit })
onClose()
} catch (e) {
console.error('PDF error:', e)
@@ -1,32 +0,0 @@
// FE-PLANNER-LOCPICKS-001 to FE-PLANNER-LOCPICKS-003
import { toLocationPicks } from './locationPicks';
describe('toLocationPicks', () => {
it('FE-PLANNER-LOCPICKS-001: a trip place becomes a pick with its name, coordinates and address', () => {
expect(toLocationPicks([{ name: ' Bach Suites Saigon ', lat: '10.78', lng: 106.7, address: 'District 1' }])).toEqual([
{ name: 'Bach Suites Saigon', lat: 10.78, lng: 106.7, address: 'District 1' },
]);
});
it('FE-PLANNER-LOCPICKS-002: a place without a name or a real coordinate is not offered, never as 0,0', () => {
expect(toLocationPicks([
{ name: 'No coordinates', lat: null, lng: null },
{ name: 'Half a coordinate', lat: 10, lng: null },
{ name: 'Blank coordinate', lat: '', lng: '' },
{ name: 'Not a number', lat: 'north', lng: 106.7 },
{ name: ' ', lat: 10, lng: 106 },
{ name: null, lat: 10, lng: 106 },
])).toEqual([]);
expect(toLocationPicks(undefined)).toEqual([]);
});
it('FE-PLANNER-LOCPICKS-003: the same place added twice is offered once, a namesake elsewhere stays', () => {
const picks = toLocationPicks([
{ name: 'Hotel', lat: 10, lng: 106 },
{ name: 'Hotel', lat: 10, lng: 106 },
{ name: 'Hotel', lat: 11, lng: 106 },
]);
expect(picks.map(p => `${p.name}@${p.lat}`)).toEqual(['Hotel@10', 'Hotel@11']);
expect(picks[0].address).toBeNull();
});
});
@@ -1,33 +0,0 @@
import type { LocationPoint } from './LocationSelect'
/** A trip place as far as a location field can offer it. */
interface PlaceLike {
name?: string | null
lat?: number | string | null
lng?: number | string | null
address?: string | null
}
/**
* The trip's places as ready-made picks for a location field.
*
* Only a place with a name and a real coordinate is offered. A place without one would
* be picked as 0,0 (`Number(null)` is a finite 0), which puts the endpoint of a booking
* in the Gulf of Guinea. The same place added twice is offered once.
*/
export function toLocationPicks(places: readonly PlaceLike[] | null | undefined): LocationPoint[] {
const picks: LocationPoint[] = []
const seen = new Set<string>()
for (const place of places ?? []) {
const name = place.name?.trim()
if (!name || place.lat == null || place.lng == null || place.lat === '' || place.lng === '') continue
const lat = Number(place.lat)
const lng = Number(place.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
const key = `${name}:${lat}:${lng}`
if (seen.has(key)) continue
seen.add(key)
picks.push({ name, lat, lng, address: place.address ?? null })
}
return picks
}
@@ -1,70 +0,0 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { Footprints } from 'lucide-react'
import FigureBadge from './FigureBadge'
// FE-FIGURE-BADGE-001 to FE-FIGURE-BADGE-005
/** The classes a cell carries, as a set, so the order they are written in does not matter. */
const classes = (el: Element | null): string[] => (el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).sort()
const of = (list: string): string[] => list.split(' ').sort()
describe('FigureBadge', () => {
it('FE-FIGURE-BADGE-001: the neutral shell is the one the stay, the walk and the fill-up wore before', () => {
const { container } = render(<FigureBadge lead={<Footprints size={9} aria-hidden />} value="240 m" />)
const shell = container.firstElementChild!
expect(classes(shell)).toEqual(of('inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'))
expect(classes(shell.children[0])).toEqual(of('flex items-center gap-0.5 px-1 bg-surface-tertiary text-content-faint'))
expect(classes(shell.children[1])).toEqual(
of('flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums border-edge text-content-secondary'),
)
expect(shell.children[1]).toHaveTextContent('240 m')
})
it('FE-FIGURE-BADGE-002: the warning tone edges both halves in the warning colour itself', () => {
const { container } = render(<FigureBadge tone="warning" dir="ltr" lead="!" value="+30 min" />)
const shell = container.firstElementChild!
expect(shell).toHaveAttribute('dir', 'ltr')
expect(classes(shell)).toContain('border-warning')
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['bg-warning-soft', 'text-warning']))
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['border-warning', 'text-warning']))
})
it('FE-FIGURE-BADGE-003: a word in the lead is a caption, a figure can be quiet, and without one the sign stands alone', () => {
const caption = render(<FigureBadge caption lead="Stay" value="+" faint valueSize="micro" />)
const shell = caption.container.firstElementChild!
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['font-geist', 'font-semibold', 'uppercase', 'tracking-[0.12em]']))
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['text-content-faint']))
expect((shell.children[1] as HTMLElement).style.fontSize).toContain('8px')
caption.unmount()
const sign = render(<FigureBadge lead={<Footprints size={9} aria-label="Walk" />} />)
expect(sign.container.firstElementChild!.children).toHaveLength(1)
expect(screen.getByLabelText('Walk')).toBeInTheDocument()
})
it('FE-FIGURE-BADGE-004: as a control it is a focusable button role that answers the keys meaning press and keeps them from the row', () => {
const onActivate = vi.fn()
const onRow = vi.fn()
render(
<div role="presentation" onClick={onRow} onKeyDown={onRow}>
<FigureBadge lead="Stay" value="1 h" onActivate={onActivate} ariaLabel="Stay: 1 h" />
</div>,
)
const badge = screen.getByRole('button', { name: 'Stay: 1 h' })
fireEvent.click(badge)
fireEvent.keyDown(badge, { key: 'Enter' })
fireEvent.keyDown(badge, { key: ' ' })
fireEvent.keyDown(badge, { key: 'Tab' })
expect(onActivate).toHaveBeenCalledTimes(3)
expect(onActivate).toHaveBeenCalledWith(badge)
// Only the Tab went on to the row.
expect(onRow).toHaveBeenCalledTimes(1)
})
it('FE-FIGURE-BADGE-005: without a handler it is no control at all', () => {
render(<FigureBadge lead="Stay" value="1 h" tooltip="How long you stay" />)
expect(screen.queryByRole('button')).toBeNull()
})
})
@@ -1,95 +0,0 @@
import React from 'react'
import { Tooltip } from '../shared/Tooltip'
import { FS } from './typeScale'
/**
* A figure in the two-part shell the rail uses everywhere: what it is on the left, the
* number on the right, one hairline between them.
*
* One component because the shell had been typed out six times, the stay, the walk, the
* fill-up, the two findings and the limits button each carrying their own copy, and the
* copies had started to drift. The warning tone keeps the border in the warning colour
* itself, not its soft tint: at this size a tinted edge disappears into the card.
*/
const TONES = {
neutral: {
shell: 'border-edge',
lead: 'bg-surface-tertiary text-content-faint',
value: 'border-edge text-content-secondary',
},
warning: {
shell: 'border-warning',
lead: 'bg-warning-soft text-warning',
value: 'border-warning text-warning',
},
} as const
interface FigureBadgeProps {
/** An icon, or a word set as a caption (`caption`). */
lead: React.ReactNode
/** The figure. Absent draws the lead alone, a badge that is only a sign. */
value?: React.ReactNode
tone?: 'neutral' | 'warning'
tooltip?: string
/** The lead is a word, set small, wide and in capitals like the stay's. */
caption?: boolean
/** The figure in the quiet ink: an invitation, or a value inherited rather than set. */
faint?: boolean
valueSize?: 'label' | 'micro'
dir?: 'ltr'
/**
* Makes the badge the control that changes its figure. A span with the button role, not
* a <button>: the stop row around it is already one, and a button inside a button is
* invalid HTML that browsers resolve by dropping the inner element. The press stops at
* the badge, so the row does not select its stop and move the map from under the panel.
*/
onActivate?: (anchor: HTMLElement) => void
/** What a screen reader hears for the control, when the badge is one. */
ariaLabel?: string
}
export default function FigureBadge({
lead, value, tone = 'neutral', tooltip, caption, faint, valueSize = 'label', dir, onActivate, ariaLabel,
}: FigureBadgeProps): React.ReactElement {
const colours = TONES[tone]
const shell = `inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border ${colours.shell}`
const body = (
<>
<span
className={`flex items-center gap-0.5 px-1 ${colours.lead} ${caption ? 'font-geist font-semibold uppercase tracking-[0.12em]' : ''}`}
style={{ fontSize: FS.micro }}
>
{lead}
</span>
{value === undefined ? null : (
<span
className={`flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums ${faint ? 'border-edge text-content-faint' : colours.value}`}
style={{ fontSize: FS[valueSize] }}
>
{value}
</span>
)}
</>
)
const badge = onActivate ? (
<span
role="button"
tabIndex={0}
dir={dir}
aria-label={ariaLabel}
onClick={e => { e.stopPropagation(); onActivate(e.currentTarget) }}
onKeyDown={e => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
onActivate(e.currentTarget)
}}
className={`${shell} cursor-pointer transition-colors hover:border-content-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
>
{body}
</span>
) : (
<span dir={dir} className={shell}>{body}</span>
)
return tooltip ? <Tooltip label={tooltip}>{badge}</Tooltip> : badge
}
@@ -5,10 +5,9 @@ import { useSettingsStore } from '../../store/settingsStore'
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
import type { AlternativeOverlay } from './alternativeOverlays'
import type { LegAlternatives } from './useRouteAlternatives'
import { openLeg } from '../../../tests/helpers/legAlternatives'
/**
* FE-ALTBAR-001..014: the ways of driving one leg, offered over the map.
* FE-ALTBAR-001..009 — 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,13 +26,14 @@ const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay =>
distance: 290_000,
slowerThanQuickest: 0,
otherEngine: false,
engine: 'osrm',
labelBg: '#0a84ff',
at: { lat: 53, lng: 11 },
...over,
})
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => openLeg({ dayId: 4, drive: { kind: 'leg', index: 1 }, ...over })
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
})
beforeEach(() => {
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
@@ -144,88 +144,18 @@ describe('RoadtripAlternativesBar', () => {
expect(onClose).toHaveBeenCalled()
})
it('FE-ALTBAR-010: an offer the other engine timed says so, naming that engine, and the rest do not', () => {
it('FE-ALTBAR-010: an offer the other engine timed says so, and the rest do not', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[
overlay({ note: 'Fastest' }),
overlay({ index: 1, note: 'No tolls', otherEngine: true, engine: 'valhalla' }),
overlay({ index: 1, note: 'No tolls', otherEngine: true }),
]}
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', () => {
// A second click would race the first, and the router's answer to it could land on
// the leg after it.
const onChoose = vi.fn()
render(
<RoadtripAlternativesBar
open={leg({ proving: 1 })}
overlays={[overlay({ index: 0, note: 'Current' }), overlay({ index: 1 }), overlay({ index: 2 })]}
onChoose={onChoose}
onClose={vi.fn()}
/>,
)
const [, current, checked, other] = screen.getAllByRole('button')
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')
fireEvent.click(other)
expect(onChoose).not.toHaveBeenCalled()
// Closing stays available: it is how a check nobody wants to wait for is abandoned.
expect(screen.getByRole('button', { name: /close/i })).not.toBeDisabled()
})
it('FE-ALTBAR-012: with nothing being checked every offer can be taken', () => {
render(
<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.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')
})
})
@@ -1,11 +1,11 @@
import React from 'react'
import { Shuffle, X, AlertTriangle, Info, Loader2 } from 'lucide-react'
import { Shuffle, X, AlertTriangle, Info } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import type { LegAlternatives } from './useRouteAlternatives'
import { alternativesBusy, alternativesPhase, alternativeSubline, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
import { alternativesPhase, alternativeSubline, type AlternativeOverlay } from './alternativeOverlays'
interface RoadtripAlternativesBarProps {
open: LegAlternatives | null
@@ -35,13 +35,6 @@ export default function RoadtripAlternativesBar({
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
if (!open) return null
const phase = alternativesPhase(open, overlays)
// 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">
@@ -71,61 +64,48 @@ export default function RoadtripAlternativesBar({
// One route back means there is genuinely only one sensible way to drive it.
<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 => {
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>
<div className="flex flex-wrap gap-1.5">
{overlays.map(alt => (
<button
key={alt.index}
type="button"
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"
>
<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>
{/* 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>
)
})}
<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>
))}
</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>
)
}
@@ -5,12 +5,12 @@ import { fireEvent, render, screen, within } from '../../../tests/helpers/render
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
/**
* FE-CATPICK-001..009 — what the corridor is asked to look for.
* FE-CATPICK-001..008 — what the corridor is asked to look for.
*
* Multi-select, so it stays open on a click: picking "fuel and charging" is one
* gesture rather than two round trips through a menu. Closed, it draws the picked
* kinds as icons only and names them to a screen reader and in its tooltip,
* because "Fuel, Charging" is the answer and "2 kinds" is a riddle.
* gesture rather than two round trips through a menu. The summary names the
* kinds rather than counting them, because "Fuel, Charging" is the answer and
* "2 kinds" is a riddle.
*/
const KEYS = ['fuel', 'charging', 'food'] as const
@@ -40,18 +40,11 @@ describe('RoadtripCategoryPicker', () => {
expect(screen.getByText('Looking for')).toBeInTheDocument()
})
it('FE-CATPICK-003: closed, it names the kinds in the order they are offered, without printing them', () => {
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => {
// Reversed on the way in: the control lists them the way it offers them, not
// the way they happened to be clicked.
picker(['food', 'fuel'])
expect(trigger()).toHaveAccessibleName('Looking for: Fuel, Food')
expect(screen.queryByText('Fuel, Food')).toBeNull()
expect(screen.queryByText('Fuel')).toBeNull()
})
it('FE-CATPICK-009: closed, every picked kind shows as its icon, however many there are', () => {
picker(['food', 'fuel', 'charging'])
expect(trigger().querySelectorAll('span.rounded-md svg')).toHaveLength(3)
expect(screen.getByText('Fuel, Food')).toBeInTheDocument()
})
it('FE-CATPICK-004: opening lists every kind', () => {
@@ -1,8 +1,6 @@
import React from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { DROPDOWN_LIST, DROPDOWN_ROW, DROPDOWN_TRIGGER, useDropdown } from './railDropdown'
import { serviceColor } from './roadtripModel'
import { FS } from './typeScale'
@@ -15,68 +13,82 @@ interface RoadtripCategoryPickerProps {
}
/**
* What to look for, as one line.
* What to look for, as one full-width control.
*
* Six pills wrapped onto three lines and took the height of the whole card for a set of
* answers that rarely changes. Folded into a dropdown it is one line, and the space goes
* to the results, which is what the column is actually for.
*
* Closed, it shows the picked kinds by their coloured icons alone, the same ones the rail
* puts on its dashed line and the map puts on the route; the names are in the list and in
* the tooltip. That leaves the line room for the day picker beside it.
* to the results — which is what the column is actually for.
*
* Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture
* rather than two round trips through a menu.
* rather than two round trips through a menu. Each row leads with its own coloured icon,
* the same one the rail puts on its dashed line and the map puts on the route.
*/
export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement {
const { t } = useTranslation()
const { open, setOpen, ref } = useDropdown<HTMLDivElement>()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointer = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open])
const picked = keys.filter(k => selected.includes(k))
// The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds"
// is a riddle. Read out and shown on hover, since the line only draws their icons.
const names = picked.map(k => t(meta[k].labelKey)).join(', ')
// is a riddle. Truncated when it runs long, which is what the icons in front are for.
const summary = selected.length
? keys.filter(k => selected.includes(k)).map(k => t(meta[k].labelKey)).join(', ')
: t('roadtrip.poi.looking')
return (
<div ref={ref} className="relative">
<Tooltip label={names} disabled={open || !picked.length}>
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-haspopup="listbox"
aria-label={picked.length ? `${t('roadtrip.poi.looking')}: ${names}` : undefined}
style={{ fontSize: FS.control }}
className={`${DROPDOWN_TRIGGER} w-full`}
>
{picked.length ? (
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
{picked.map(k => {
const Icon = meta[k].Icon
return (
<span
key={k}
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md"
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable: road-signage palette
>
<Icon size={10} strokeWidth={2.2} aria-hidden />
</span>
)
})}
</span>
) : (
<span className="min-w-0 flex-1 truncate text-content-faint">{t('roadtrip.poi.looking')}</span>
)}
<ChevronDown
size={14}
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
</Tooltip>
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-haspopup="listbox"
style={{ fontSize: FS.control }}
className="flex h-[32px] w-full items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint"
>
<span className="flex shrink-0 items-center gap-1">
{keys.filter(k => selected.includes(k)).slice(0, 3).map(k => {
const Icon = meta[k].Icon
return (
<span
key={k}
className="grid h-[18px] w-[18px] place-items-center rounded-md"
// theme-lint-disable — the road-signage palette in `roadtripModel`.
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable — road-signage palette
>
<Icon size={10} strokeWidth={2.2} aria-hidden />
</span>
)
})}
</span>
<span className={`min-w-0 flex-1 truncate ${selected.length ? 'font-medium text-content' : 'text-content-faint'}`}>
{summary}
</span>
<ChevronDown
size={14}
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
{open ? (
<div role="listbox" aria-multiselectable className={`${DROPDOWN_LIST} start-0`}>
<div
role="listbox"
aria-multiselectable
className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur"
>
{keys.map(key => {
const { labelKey, Icon } = meta[key]
const on = selected.includes(key)
@@ -89,15 +101,15 @@ export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle
// Stays open: picking two kinds is one gesture, not two.
onClick={() => onToggle(key)}
style={{ fontSize: FS.control }}
className={DROPDOWN_ROW}
className="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
>
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable: road-signage palette
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable — road-signage palette
>
<Icon size={12} strokeWidth={2} aria-hidden />
</span>
<span className={`min-w-0 flex-1 truncate pe-4 ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
<span className={`min-w-0 flex-1 truncate ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
{t(labelKey)}
</span>
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
@@ -138,25 +138,6 @@ const searchRow = (): HTMLButtonElement[] => {
}
describe('RoadtripCorridorPanel', () => {
it('FE-ROADTRIP-PANEL-036: the day sits beside what is looked for, not in the header, and picking one moves the search', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1), day(2, 2)])} />)
const dayTrigger = screen.getByText('Day 1').closest('button')!
expect(dayTrigger.closest('header, h2')).toBeNull()
// One line: the kinds and the day share the row under "Looking for".
const row = dayTrigger.parentElement!.parentElement!
expect(row.querySelector('[aria-haspopup="listbox"]')).not.toBe(dayTrigger)
expect(row.querySelectorAll('[aria-haspopup="listbox"]')).toHaveLength(2)
fireEvent.click(dayTrigger)
fireEvent.click(screen.getByRole('option', { name: /Day 2/ }))
expect(c.setDayId).toHaveBeenCalledWith('2')
})
it('FE-ROADTRIP-PANEL-037: a trip of one day offers no day to pick', () => {
wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1)])} />)
expect(screen.queryByText('Day 1')).toBeNull()
})
it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
@@ -531,8 +512,8 @@ describe('RoadtripCorridorPanel', () => {
})
it('FE-ROADTRIP-PANEL-032: a narrow panel gives the heading up before it gives up a control', () => {
// The heading repeats what the column already is; the controls are what somebody came
// here to change.
// The heading repeats what the column already is, and the day picker beside it is
// the part somebody came here to change.
panelWidth.value = 320
const { unmount } = wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />)
expect(screen.getByRole('heading', { name: 'Along the route' })).toBeInTheDocument()
@@ -11,7 +11,6 @@ import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
import RoadtripDayPicker from './RoadtripDayPicker'
import { serviceColor } from './roadtripModel'
import { alongLabel, offRouteLabel } from './corridorSearchModel'
import { CORRIDOR_CATEGORY_BY_KEY, manualStopKindFor } from './stopKinds'
@@ -297,9 +296,9 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi, onFocusP
* the list answers "which of these" and the map answers "which side of the road".
*
* Three cards down one column, the same shapes the rail opposite uses: what to look for,
* how the search is getting on, and what it found. The day it searches sits beside what
* it looks for, in the same dropdown, so the question reads as one line: these kinds,
* along this day.
* how the search is getting on, and what it found. The day it searches sits in the
* header rather than among the filters — it is the question's subject, not one of its
* conditions.
*/
export default function RoadtripCorridorPanel({
corridor, routes, onAddPoi, onFocusPoint, onAddManual, tripId, canImport,
@@ -320,6 +319,11 @@ export default function RoadtripCorridorPanel({
const panel = useElementSize<HTMLDivElement>()
const narrow = panel.width > 0 && panel.width < NARROW_PANEL_PX
const dayOptions = routes.days.map(d => ({
value: String(d.dayId),
label: t('roadtrip.day', { number: d.dayNumber }),
}))
const grouped = CORRIDOR_CATEGORY_KEYS
.map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) }))
.filter(g => g.pois.length > 0)
@@ -374,37 +378,40 @@ export default function RoadtripCorridorPanel({
return (
<div ref={panel.ref} className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
{/* Header: the panel's name. Pulled at, it is the first thing to go: it repeats
what the column already is, and a heading that shortens to an ellipsis says less
than no heading at all. */}
{/* Header: the panel's name, and the day the question is about.
Pulled at, the name is the first thing to go. It repeats what the column
already is, the day picker beside it is the part somebody came here to change,
and a heading that shortens to an ellipsis says less than no heading at all. */}
<div className="flex flex-shrink-0 items-center gap-2.5 px-1">
{narrow ? null : (
<h2 className="min-w-0 truncate font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}>
{t('roadtrip.poi.title')}
</h2>
)}
{tripId !== undefined && tripId > 0 && canImport && (
<span className="ms-auto"><GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} /></span>
)}
{dayOptions.length > 1 ? (
<div className="ms-auto min-w-0">
<CustomSelect
value={corridor.dayId}
onChange={value => corridor.setDayId(String(value))}
options={dayOptions}
size="sm"
/>
</div>
) : null}
{tripId !== undefined && tripId > 0 && canImport && <GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} />}
</div>
{/* What to look for. */}
<div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}>
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<RoadtripCategoryPicker
keys={CORRIDOR_CATEGORY_KEYS}
meta={CATEGORY_META}
selected={corridor.categories}
onToggle={corridor.toggleCategory}
/>
</div>
{routes.days.length > 1 ? (
<RoadtripDayPicker days={routes.days} value={corridor.dayId} onChange={corridor.setDayId} />
) : null}
</div>
<RoadtripCategoryPicker
keys={CORRIDOR_CATEGORY_KEYS}
meta={CATEGORY_META}
selected={corridor.categories}
onToggle={corridor.toggleCategory}
/>
</div>
<span className="h-px bg-edge-faint" aria-hidden />
@@ -1,53 +0,0 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import RoadtripDayPicker from './RoadtripDayPicker'
/**
* FE-DAYPICK-001..004 — the day the corridor searches along, in the same dropdown as what
* it looks for.
*/
const DAYS = [{ dayId: 11, dayNumber: 1 }, { dayId: 12, dayNumber: 2 }, { dayId: 13, dayNumber: 3 }]
function picker(value = '12', onChange = vi.fn()) {
render(<RoadtripDayPicker days={DAYS} value={value} onChange={onChange} />)
return { onChange, trigger: () => screen.getAllByRole('button')[0] }
}
describe('RoadtripDayPicker', () => {
it('FE-DAYPICK-001: closed, it names the picked day and nothing else', () => {
const { trigger } = picker()
expect(trigger()).toHaveTextContent('Day 2')
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('listbox')).toBeNull()
})
it('FE-DAYPICK-002: opened, it lists every day and marks the picked one', () => {
const { trigger } = picker()
fireEvent.click(trigger())
const options = screen.getAllByRole('option')
expect(options.map(o => o.textContent)).toEqual(['1Day 1', '2Day 2', '3Day 3'])
expect(options[1]).toHaveAttribute('aria-selected', 'true')
expect(options[0]).toHaveAttribute('aria-selected', 'false')
})
it('FE-DAYPICK-003: picking a day reports its id and closes, a single choice being one gesture', () => {
const { trigger, onChange } = picker()
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('option', { name: /Day 3/ }))
expect(onChange).toHaveBeenCalledWith('13')
expect(screen.queryByRole('listbox')).toBeNull()
})
it('FE-DAYPICK-004: a click outside or Escape closes it without a choice', () => {
const { trigger, onChange } = picker()
fireEvent.click(trigger())
fireEvent.mouseDown(document.body)
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(trigger())
fireEvent.keyDown(document, { key: 'Escape' })
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(onChange).not.toHaveBeenCalled()
})
})
@@ -1,75 +0,0 @@
import React from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { DROPDOWN_LIST, DROPDOWN_ROW, DROPDOWN_TRIGGER, useDropdown } from './railDropdown'
import { FS } from './typeScale'
interface RoadtripDayPickerProps {
days: readonly { dayId: number; dayNumber: number }[]
/** The picked day's id, as the corridor keeps it. */
value: string
onChange: (dayId: string) => void
}
/**
* The day whose drive the corridor searches along, in the same dropdown as what it looks
* for and on the same line, so the question reads as one: these kinds, along this day.
* Each day leads with its number where a kind leads with its icon.
*/
export default function RoadtripDayPicker({ days, value, onChange }: RoadtripDayPickerProps): React.ReactElement {
const { t } = useTranslation()
const { open, setOpen, ref } = useDropdown<HTMLDivElement>()
const current = days.find(d => String(d.dayId) === value)
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-haspopup="listbox"
style={{ fontSize: FS.control }}
className={DROPDOWN_TRIGGER}
>
<span className="whitespace-nowrap font-medium text-content">
{current ? t('roadtrip.day', { number: current.dayNumber }) : null}
</span>
<ChevronDown
size={14}
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
{open ? (
<div role="listbox" className={`${DROPDOWN_LIST} end-0 max-h-[18rem] overflow-y-auto`}>
{days.map(day => {
const on = String(day.dayId) === value
return (
<button
key={day.dayId}
type="button"
role="option"
aria-selected={on}
onClick={() => { onChange(String(day.dayId)); setOpen(false) }}
style={{ fontSize: FS.control }}
className={DROPDOWN_ROW}
>
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px] bg-surface-tertiary font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{day.dayNumber}
</span>
<span className={`min-w-0 flex-1 truncate pe-4 ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
{t('roadtrip.day', { number: day.dayNumber })}
</span>
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
</button>
)
})}
</div>
) : null}
</div>
)
}
@@ -277,59 +277,6 @@ describe('RoadtripLimitsCard', () => {
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
})
it('FE-ROADTRIP-LIMITS-018: starting and ending each day at the stay is off until it is asked for', () => {
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
// Missing is off: a trip drives as it always has until somebody switches it on.
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', true)
expect(onSave).toHaveBeenCalledTimes(1)
// What it does is said under it for everybody to read, and the switch is described by
// that sentence, so a screen reader hears it with the switch rather than never.
const hint = screen.getByText(
'After a booked night the day starts at that stay, and before one it ends at the stay booked for that night.',
)
expect(toggle).toHaveAttribute('aria-describedby', hint.id)
expect(hint.id).not.toBe('')
expect(screen.queryByRole('tooltip')).toBeNull()
// A switch that needs no sentence is described by none.
expect(screen.getByRole('button', { name: 'Connect the days' })).not.toHaveAttribute('aria-describedby')
})
it('FE-ROADTRIP-LIMITS-019: switched on, it reads on and reports the way back off', () => {
useSettingsStore.setState({
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', false)
})
it('FE-ROADTRIP-LIMITS-021: switched on, the button says so with the bed, instead of saying nothing is set', () => {
const off = render(<RoadtripLimitsCard onSave={vi.fn()} />)
expect(screen.getByText('None set')).toBeInTheDocument()
expect(off.container.querySelector('svg.lucide-bed-double')).toBeNull()
off.unmount()
useSettingsStore.setState({
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
})
const { container } = render(<RoadtripLimitsCard onSave={vi.fn()} />)
expect(screen.queryByText('None set')).toBeNull()
const bed = container.querySelector('svg.lucide-bed-double')!
expect(bed).toHaveAttribute('aria-label', 'Start and end each day at your stay')
// A sign alone: the switch has no figure to put beside it.
expect(bed.parentElement!.nextElementSibling).toBeNull()
expect(screen.getByRole('button')).toHaveAccessibleName(expect.stringContaining('Start and end each day at your stay'))
})
})
/**
@@ -366,16 +313,6 @@ describe('RoadtripLimitsCard', () => {
expect(screen.getByText('Connect the days')).toBeInTheDocument()
expect(screen.getByText('A colour per day')).toBeInTheDocument()
})
it('FE-ROADTRIP-LIMITS-020: the stay switch shows its row and offers no switch', () => {
useSettingsStore.setState({
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
})
open(undefined)
expect(screen.queryByRole('button', { name: 'Start and end each day at your stay' })).not.toBeInTheDocument()
expect(screen.getByText('Start and end each day at your stay')).toBeInTheDocument()
})
})
})
@@ -1,5 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useId, useState } from 'react'
import React, { useState } from 'react'
import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
@@ -18,9 +18,6 @@ import { FS } from './typeScale'
import DayWindowFields from './DayWindowFields'
import SettingsHint from './SettingsHint'
import { dayWindow } from './dayWindow'
import { BOOKEND_ICON } from './nightBookend'
import { useHotelBookends } from './useHotelBookends'
import FigureBadge from './FigureBadge'
/**
* Everything that decides when the rail speaks up about the driving.
@@ -165,30 +162,21 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
* Disabled rather than hidden when there is no engine that can answer: an operator who
* pointed the instance at their own OSRM has no second engine, and a switch that flips
* and changes nothing is worse than one that says why it cannot.
*
* A `hint` is the sentence a switch needs to say what flipping it changes. It stands under
* the label, in the caption the panels close on, and the switch is described by it: behind a
* hover it was a sentence nobody found, and a screen reader never heard.
*/
function AvoidRow({ icon: Icon, label, hint, on, disabled, onToggle }: {
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
icon: typeof Coins
label: string
hint?: string
on: boolean
disabled: boolean
onToggle: () => void
}): React.ReactElement {
const hintId = useId()
return (
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-body text-content-secondary">{label}</span>
{hint ? <span id={hintId} className="text-caption leading-snug text-content-faint">{hint}</span> : null}
</span>
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
{disabled
? <span className="text-caption text-content-faint">{'—'}</span>
: <ToggleSwitch on={on} onToggle={onToggle} label={label} describedBy={hint ? hintId : undefined} />}
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
</div>
)
}
@@ -293,7 +281,6 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
// days gets the dialog with the trip's figures in it, not a form that takes a number
// and drops it.
const readOnly = !onSave
const bookends = useHotelBookends(onSave)
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
@@ -398,10 +385,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
avoiding.length && canAvoid
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
: null,
// A switch has no figure, so its badge is the sign alone and its words are the tooltip.
// Without it a trip whose days start and end at the stay said "None set" on the button.
bookends.on ? { key: 'bookends', Icon: BOOKEND_ICON, label: t('roadtrip.line.hotelBookends') } : null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text?: string; label?: string }[]
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
return (
<>
@@ -425,13 +409,21 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
</span>
{badges.length ? (
<span className="flex flex-wrap items-center gap-1">
{badges.map(({ key, Icon, text, label }) => (
<FigureBadge
{badges.map(({ key, Icon, text }) => (
<span
key={key}
lead={label ? <Icon size={9} aria-label={label} /> : <Icon size={9} aria-hidden />}
value={text}
tooltip={label}
/>
className="inline-flex h-[16px] items-stretch overflow-hidden rounded border border-edge"
>
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
<Icon size={9} aria-hidden />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{text}
</span>
</span>
))}
</span>
) : (
@@ -665,18 +657,6 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
disabled={readOnly}
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
/>}
{/* Where a day begins and ends rather than what joins them: after a booked
night the drive sets off from the stay, and before one it ends there.
Off by default, because it adds a leg to most days of a trip that has
stays and changes their kilometres and times. */}
<AvoidRow
icon={BOOKEND_ICON}
label={t('roadtrip.line.hotelBookends')}
hint={t('roadtrip.line.hotelBookendsHint')}
on={bookends.on}
disabled={!bookends.toggle}
onToggle={() => bookends.toggle?.()}
/>
{/* Which matters most once the line IS continuous: end to end it is one
stroke, and a colour per day is what puts the days back into it. */}
<AvoidRow
@@ -1,15 +1,7 @@
import React from 'react'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { act, render, screen, within, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
// The rail measures itself to drop the stop counts when pulled narrow. Zero reads as "not
// measured yet", which keeps every figure, and is the default here.
const { railWidth } = vi.hoisted(() => ({ railWidth: { value: 0 } }))
vi.mock('../../hooks/useElementSize', () => ({
useElementSize: () => ({ ref: () => {}, width: railWidth.value, height: 0 }),
}))
afterEach(() => { railWidth.value = 0 })
import RoadtripSidebar from './RoadtripSidebar'
import RoadtripModeSwitch from './RoadtripModeSwitch'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
@@ -88,31 +80,6 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
}
}
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
describe('RoadtripSidebar', () => {
/**
* The value the summary head pairs with this label. The same words also name the chips
@@ -135,22 +102,6 @@ describe('RoadtripSidebar', () => {
expect(total('Stops')).toBe('5')
})
it('FE-ROADTRIP-SIDEBAR-083: pulled narrow, the rail stops counting stops, in its head and on every day', () => {
const wide = wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
expect(screen.getByText('2 stops')).toBeInTheDocument()
wide.unmount()
railWidth.value = 300
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
expect(total('Distance')).toBe('250 km')
expect(total('Driving time')).toBe('2 h 30 min')
const head = screen.getByText('Distance').closest('header')!
// The figure goes with its hairline, so the two left share the width.
expect(within(head).queryByText('Stops')).toBeNull()
expect(head.querySelectorAll('span.w-px')).toHaveLength(1)
expect(screen.queryByText('2 stops')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
// Trip and day totals deliberately differ from the single leg, so the leg's own two
// values are the only place '100 km' and '1 h' can come from.
@@ -634,16 +585,11 @@ describe('RoadtripSidebar', () => {
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car, a ride keeps its own', () => {
const unknown = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'hovercraft' })] })] })} />)
expect(unknown.container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
unknown.unmount()
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
const ferry = leg({ mode: 'ferry' })
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
// A ferry leg is a booked ride since the carrier seams (#2429), not a drive
// whose mode nobody drew an icon for.
const ride = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'ferry' })] })] })} />)
expect(ride.container.querySelector('svg.lucide-sailboat')).toBeInTheDocument()
expect(ride.container.querySelector('svg.lucide-car-front')).toBeNull()
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
@@ -760,6 +706,31 @@ describe('RoadtripSidebar', () => {
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
*/
describe('the leg the tank runs out on', () => {
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
osm_id: `osm-${name}`,
name,
@@ -1160,24 +1131,13 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
})
}
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes, both codes with their timetable, the places and the check-in, and no number', () => {
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes over both terminals with their timetable, and no number', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
expect(screen.getByText('1 h 10 min')).toBeInTheDocument()
expect(screen.queryByText('LH 2020 · 1 h 10 min')).toBeNull()
// Each code, each timetable clock and each place once.
expect(screen.getAllByText('HAM')).toHaveLength(1)
expect(screen.getAllByText('MUC')).toHaveLength(1)
expect(screen.getAllByText('13:20')).toHaveLength(1)
expect(screen.getAllByText('14:30')).toHaveLength(1)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
// What the clocks are is said to a screen reader, not printed a second time.
expect(screen.getByText('Departure 13:20')).toHaveClass('sr-only')
expect(screen.getByText('Arrival 14:30')).toHaveClass('sr-only')
// The check-in, labelled, where an unlabelled 12:20 used to stand in the arrival column.
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('12:20')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
expect(screen.getByText('Arrival 14:30')).toBeInTheDocument()
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
// Hamburg is 1 and Munich is 2: the terminals between them take no number, and
// the day header counts two stops.
expect(screen.queryByText('3')).not.toBeInTheDocument()
@@ -1189,9 +1149,9 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />)
// One control for the whole ride, not one per terminal and one for the band.
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
fireEvent.click(screen.getByText('Hamburg Airport'))
fireEvent.click(screen.getByText('LH 2020'))
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
expect(onOpenBooking).toHaveBeenCalledTimes(2)
expect(onOpenBooking).toHaveBeenCalledWith(70)
// One shuffle: the road into the departure terminal. Not the ride, not the road out
@@ -1199,17 +1159,6 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
it('FE-ROADTRIP-SIDEBAR-059: asking names the drive as a leg of its card, not as a bare position', () => {
// A tagged drive rather than an index, so the road arriving at the head of a card can
// be asked about as what it is instead of under an index no stop has.
const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={vi.fn()} onAskAlternatives={onAskAlternatives} />)
fireEvent.click(screen.getByLabelText('Other ways'))
expect(onAskAlternatives).toHaveBeenCalledWith(1, { kind: 'leg', index: 0 })
})
it('FE-ROADTRIP-SIDEBAR-051: a ride landing tomorrow leaves its departure as a row of its own, with the road into it', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), terminal('departure')]
const overnight = day({
@@ -1224,68 +1173,12 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
},
})
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
// The block's pieces on one row: the code and the place, the timetable's clock on the
// right, and the check-in under them.
const row = screen.getByText('Hamburg Airport').closest('button')!
expect(within(row).getByText('HAM')).toBeInTheDocument()
expect(within(row).getByText('13:20')).toBeInTheDocument()
expect(within(row).getByText('Check-in')).toBeInTheDocument()
expect(within(row).getByText('12:20')).toBeInTheDocument()
// The chain's arrival there is the check-in, not a second clock in the column.
expect(screen.queryByText('21:00')).toBeNull()
expect(screen.queryByText('LH 2020')).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
expect(screen.queryByText('LH 2020 · 1 h 10 min')).not.toBeInTheDocument()
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
/** The card a ride lands on, joined to the card before it by the ride itself. */
const landingDay = (arrivingLeg: RouteSegment) => {
const arrival = terminal('arrival')
return day({
dayId: 2,
dayNumber: 2,
stops: [{ ...arrival, time: '07:00', carrier: { ...arrival.carrier!, at: '07:00' } }, stop({ assignmentId: 2, name: 'Munich' })],
legs: [leg()],
arrivingLeg,
schedule: {
entries: [
{ arrival: '07:00', departure: '07:00', anchored: true, dayOffset: 0 },
{ arrival: '08:00', departure: '08:00', anchored: false, dayOffset: 0 },
],
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-054: the join into the day a ride lands on is the ride, not a car driving no distance', () => {
const flown = leg({ mode: 'flight', distance: 0, duration: 9 * 3600, distanceText: '', durationText: '9 h' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(flown)], totalStops: 1 })} />)
// The booking and its minutes, the minutes standing apart so a long title cannot cut them off.
const band = screen.getByText('LH 2020').parentElement!
expect(within(band).getByText('9 h')).toHaveClass('shrink-0')
expect(screen.queryByText(/0 m in 9 h/)).not.toBeInTheDocument()
// Under the booking's own icon, not the car the road out of the airport wears.
expect(band.querySelector('svg.lucide-plane')).toBeInTheDocument()
expect(band.querySelector('svg.lucide-car-front')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-055: a ride without a timetable still names the booking rather than passing for a hop', () => {
const untimed = leg({ mode: 'flight', distance: 0, duration: 0, distanceText: '', durationText: '' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(untimed)], totalStops: 1 })} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-056: the tank starts afresh after a flight, and the road out of the arrival can be the one it runs out on', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...flightDay(), dryPoints: [dry({ legIndex: 2 })] }], totalStops: 2 })} refuel={search()} />,
)
// Hamburg, the ride block, Munich: the band hangs under the block, where the road
// out of the arrival leaves, and nowhere else.
const rows = container.querySelectorAll('li')
expect(rows).toHaveLength(3)
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
})
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
@@ -1301,9 +1194,9 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
const rentalDay = () => {
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
return day({
const rental = day({
stops,
legs: [leg(), leg(), leg()],
schedule: {
@@ -1316,22 +1209,11 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
// Each desk says which one it is, having no other end beside it, and its clock once.
const pickup = screen.getByText('Sixt Hauptbahnhof').closest('button')!
expect(within(pickup).getByText('Pickup')).toBeInTheDocument()
expect(within(pickup).getAllByText('09:00')).toHaveLength(1)
expect(within(pickup).getByText('Pick-up 09:00')).toHaveClass('sr-only')
const drop = screen.getByText('Sixt Airport').closest('button')!
expect(within(drop).getByText('Return')).toBeInTheDocument()
expect(within(drop).getAllByText('11:30')).toHaveLength(1)
expect(within(drop).getByText('HAM')).toBeInTheDocument()
// A desk has no check-in.
expect(screen.queryByText('Check-in')).toBeNull()
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.queryByText('3')).not.toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
fireEvent.click(screen.getByText('Sixt Airport'))
@@ -1341,15 +1223,6 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2)
})
it('FE-ROADTRIP-SIDEBAR-057: a tank that empties on the road out of the pick-up desk is offered a fill-up under the desk', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...rentalDay(), dryPoints: [dry({ legIndex: 0 })] }], totalStops: 2 })} refuel={search()} />,
)
const rows = container.querySelectorAll('li')
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[0] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
@@ -1360,7 +1233,7 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} canEditBookings />)
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
@@ -1374,443 +1247,4 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
// The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
booking({ id: 12, title: 'Taxi zum Hafen', type: 'taxi', reservation_time: '2026-10-05T18:30', assignment_id: 1 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
// The table is there to read, not a button that does nothing; the taxi is a button.
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpenBooking).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi zum Hafen'))
expect(onOpenBooking).toHaveBeenCalledWith(12)
})
})
describe('RoadtripSidebar with the drive in from the day before (#2461)', () => {
/** Where day 1 ended, three stops in, and a card joined to it by the road from there. */
const yesterday = stop({ assignmentId: 9, name: 'Lüneburg', ownerDayId: 1, ownerIndex: 2 })
const joined = (over: Partial<RoadtripDay> = {}) => day({
dayId: 2,
dayNumber: 2,
arrivingLeg: leg({ distance: 120000, duration: 5400, distanceText: '120 km', durationText: '1 h 30 min' }),
arrivingFrom: yesterday,
arrivingLine: [[53.2, 10.4], [52.5, 13.4]],
...over,
})
const pressed = () => screen.getAllByLabelText('Other ways').map(icon => icon.closest('button')?.getAttribute('aria-pressed'))
it('FE-ROADTRIP-SIDEBAR-060: the drive in offers other ways as the drive it is, and shows pressed while they are open', () => {
// #2461: the band above the first stop was the one drive on the rail without the
// control, so the road between two days could not be changed at all.
const onAskAlternatives = vi.fn()
const first = wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={onAskAlternatives} />)
// The drive in, then the card's own leg.
expect(pressed()).toEqual(['false', 'false'])
fireEvent.click(screen.getAllByLabelText('Other ways')[0])
expect(onAskAlternatives).toHaveBeenCalledWith(2, { kind: 'arriving' })
first.unmount()
const open = wrap(
<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={vi.fn()} openAlternatives={{ dayId: 2, drive: { kind: 'arriving' } }} />,
)
expect(pressed()).toEqual(['true', 'false'])
open.unmount()
// The card's first leg is another drive, even at the index the drive in would borrow.
wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={vi.fn()} openAlternatives={{ dayId: 2, drive: { kind: 'leg', index: 0 } }} />)
expect(pressed()).toEqual(['false', 'true'])
})
it('FE-ROADTRIP-SIDEBAR-061: no control for a reader, for a drive from a terminal, or for one the rail has no line for', () => {
const reader = wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} />)
expect(screen.queryAllByLabelText('Other ways')).toHaveLength(0)
// The band is still drawn, only not as a control.
expect(screen.getByText('120 km in 1 h 30 min')).toBeInTheDocument()
reader.unmount()
const landed = stop({
assignmentId: -3000000141,
name: 'Munich Airport',
carrier: { reservationId: 70, type: 'flight', role: 'arrival', title: 'LH 2020', code: 'MUC', at: '14:30' },
})
const fromTerminal = wrap(<RoadtripSidebar routes={routes({ days: [joined({ arrivingFrom: landed })] })} onAskAlternatives={vi.fn()} />)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
fromTerminal.unmount()
wrap(<RoadtripSidebar routes={routes({ days: [joined({ arrivingLine: undefined })] })} onAskAlternatives={vi.fn()} />)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
describe('a ride on no day (#2461)', () => {
const terminals = [
{ 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 },
]
const ferry = (over: Partial<Reservation>): Reservation =>
({ id: 70, trip_id: 1, type: 'ferry', title: 'IJmuiden to Newcastle', status: 'confirmed', day_id: null, endpoints: terminals, ...over }) as unknown as Reservation
it('FE-ROADTRIP-SIDEBAR-062: is named under the totals with its booking a click away; one on a day or without terminals is not', () => {
const onOpenBooking = vi.fn()
const reservations = [
ferry({}),
// On a day it is on the drive already; without terminals nothing says where it runs.
ferry({ id: 71, title: 'Dated ferry', day_id: 1 }),
ferry({ id: 72, title: 'Unlocated ferry', endpoints: [] } as Partial<Reservation>),
ferry({ id: 73, title: 'Table', type: 'restaurant' }),
]
wrap(<RoadtripSidebar routes={routes()} reservations={reservations} onOpenBooking={onOpenBooking} />)
const notice = screen.getByRole('status')
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)
})
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 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()
wrap(<RoadtripSidebar routes={routes()} reservations={[ferry({ day_id: 1 })]} onOpenBooking={vi.fn()} />)
expect(screen.queryByRole('status')).toBeNull()
})
})
})
describe('RoadtripSidebar with a booked night at the edge of the day', () => {
const bookend = (phase: 'morning' | 'evening', over: Partial<NonNullable<RoadtripStop['bookend']>> = {}, at: Partial<RoadtripStop> = {}) =>
stop({
assignmentId: phase === 'morning' ? -6_000_000_002 : -6_000_000_003,
name: 'Hotel Alpenblick',
placeId: 900,
lat: 47.2,
lng: 11.4,
stopType: 'hotel',
dwellMinutes: 0,
bookend: { phase, accommodationId: 5, reservationId: 41, checkingOut: false, checkingIn: false, checkOut: null, ...over },
...at,
})
/** Out of the hotel on its check-out morning, two places, and into the night's hotel. */
const loop = (over: Partial<RoadtripDay> = {}) => {
const stops = [
bookend('morning', { checkingOut: true, checkOut: '10:00' }, { ownerIndex: 0 }),
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
stop({ assignmentId: 2, name: 'Falls', ownerIndex: 1 }),
bookend('evening', {}, { ownerIndex: 2 }),
]
return day({
stops,
legs: [leg(), leg(), leg()],
schedule: {
entries: ['08:40', '09:40', '10:40', '11:40'].map(arrival => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })),
warnings: [],
},
...over,
})
}
it('FE-ROADTRIP-SIDEBAR-064: names the hotel it sets out from and the one it ends at, and counts neither', () => {
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
// The hotel is the row's name; which edge of the stay it is, and the hour, sit in its badge.
expect(screen.getAllByText('Hotel Alpenblick')).toHaveLength(2)
expect(screen.getByText('Check-out').closest('button')).toHaveTextContent('10:00')
expect(screen.getByText('Overnight')).toBeInTheDocument()
expect(screen.queryByText(/·/)).toBeNull()
expect(screen.getByText('2 stops')).toBeInTheDocument()
// The places are one and two; the hotel wears no number.
expect(screen.getByText('Lookout').closest('button')).toHaveTextContent('1')
expect(screen.getByText('11:40')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-074: a check-out morning left after the room is handed back says so, one left in time does not', () => {
const late = loop({
schedule: {
entries: ['12:27', '13:27', '14:27', '15:27'].map(arrival => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })),
warnings: [],
},
})
// The check-out badge itself turns, instead of a sentence added under the row.
const { unmount } = wrap(<RoadtripSidebar routes={routes({ days: [late] })} />)
expect(screen.getByText('Check-out').className).toContain('text-warning')
unmount()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
expect(screen.getByText('Check-out').className).not.toContain('text-warning')
})
it('FE-ROADTRIP-SIDEBAR-065: the hotel rows are neither dragged nor dropped on, and carry no stay, kind or fill control', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={vi.fn()} onEditStay={vi.fn()} onSetStopKind={vi.fn()} onSetStopFill={vi.fn()} />,
)
expect(container.querySelectorAll('li[draggable="true"]')).toHaveLength(2)
const hotelRow = screen.getByText('Overnight').closest('li')!
expect(hotelRow).not.toHaveAttribute('draggable')
// The row is one button, with nothing inside it to press.
expect(within(hotelRow).getAllByRole('button')).toHaveLength(1)
expect(within(hotelRow).queryByText('Stay')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-066: offers other ways on the drive between the places only', () => {
const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onAskAlternatives={onAskAlternatives} />)
const offered = screen.getAllByLabelText('Other ways')
expect(offered).toHaveLength(1)
fireEvent.click(offered[0])
expect(onAskAlternatives).toHaveBeenCalledWith(1, { kind: 'leg', index: 1 })
})
it('FE-ROADTRIP-SIDEBAR-067: opens the booking behind the night for an editor, the hotel for anybody else', () => {
const onOpenBooking = vi.fn()
const onSelectStop = vi.fn()
const editor = wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} canEditBookings />)
fireEvent.click(screen.getByText('Overnight'))
expect(onOpenBooking).toHaveBeenCalledWith(41)
expect(onSelectStop).not.toHaveBeenCalled()
editor.unmount()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} />)
fireEvent.click(screen.getByText('Check-out'))
// The place alone, without an assignment: the hotel is no stop of the day.
expect(onSelectStop).toHaveBeenCalledWith(900)
expect(onOpenBooking).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-SIDEBAR-068: a place is not moved past the hotel at either end of its day', () => {
const onReorderStop = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={onReorderStop} />)
fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowUp', altKey: true })
fireEvent.keyDown(screen.getByText('Falls').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).not.toHaveBeenCalled()
fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).toHaveBeenCalledWith(1, 1, 1)
})
it('FE-ROADTRIP-SIDEBAR-069: the morning marker at the hotel is drawn as the hotel row, not as a line of its own', () => {
const resume = stop({
assignmentId: -2_000_000_003,
name: 'Continue journey',
lat: 47.2,
lng: 11.4,
automaticNight: { phase: 'start', fromDayNumber: 1 },
})
const stops = [resume, bookend('morning', {}, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg({ distance: 0 }), leg()] })] })} />)
expect(screen.getByText('Day start')).toBeInTheDocument()
expect(screen.queryByText('Continue journey')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-070: a drive to the hotel over the limit is flagged on the hotel row', () => {
wrap(<RoadtripSidebar routes={routes({ days: [loop({ driveWarnings: [{ index: 3, code: 'leg', overMinutes: 30 }] })] })} />)
const hotelRow = screen.getByText('Overnight').closest('li')!
expect(within(hotelRow).getByText('+30 min')).toBeInTheDocument()
})
/** Whether a row's disc has the rail running into it from above. */
const lineAbove = (text: string): boolean => {
const rail = screen.getByText(text).closest('button')!.firstElementChild!
return rail.firstElementChild!.className.includes('w-[1.5px]')
}
it('FE-ROADTRIP-SIDEBAR-071: the hotel row the morning marker went into starts the rail, with no line running in from nowhere', () => {
const resume = stop({
assignmentId: -2_000_000_003,
name: 'Continue journey',
lat: 47.2,
lng: 11.4,
automaticNight: { phase: 'start', fromDayNumber: 1 },
})
const stops = [resume, bookend('morning', { checkingOut: true }, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg({ distance: 0 }), leg()] })] })} />)
expect(lineAbove('Check-out')).toBe(false)
// Below it the rail runs on as always.
expect(lineAbove('Lookout')).toBe(true)
})
/** A transfer day: out of one stay and into the next, with nothing stored in between. */
const transfer = () => day({
dayId: 2,
dayNumber: 2,
stops: [
bookend('morning', { checkingOut: true }, { ownerIndex: 0 }),
bookend('evening', { accommodationId: 6, checkingIn: true }, { ownerIndex: 0, name: 'Wallinga', lat: 48, lng: 12 }),
],
legs: [leg()],
})
it('FE-ROADTRIP-SIDEBAR-072: a day that only drives from one stay to the next says its drive and no count of stops', () => {
wrap(<RoadtripSidebar routes={routes({ days: [transfer()] })} />)
expect(screen.getByText('Check-in').closest('button')).toHaveTextContent('Wallinga')
const header = screen.getByText('Day 2').closest('header')!
expect(within(header).getByText(/100 km/)).toBeInTheDocument()
expect(within(header).queryByText(/stops?$/)).toBeNull()
expect(screen.queryByText('0 stops')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-073: a stop dragged onto such a day lands as its first stop, dropped on either hotel', () => {
const onMoveStopToDay = vi.fn()
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [day(), transfer()] })} onReorderStop={vi.fn()} onMoveStopToDay={onMoveStopToDay} />,
)
const hotelRow = screen.getByText('Wallinga').closest('li')!
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
fireEvent.dragOver(hotelRow)
expect(hotelRow.className).toContain('ring-accent')
fireEvent.drop(hotelRow)
// From day 1, its second stop (assignment 2), onto day 2 as its first.
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 2, 0)
})
it('FE-ROADTRIP-SIDEBAR-081: the hotel at the edge of the day wears the hotel stop\'s own disc, not a terminal\'s', () => {
const stops = [
bookend('morning', { checkingOut: true }, { ownerIndex: 0 }),
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
stop({ assignmentId: 2, name: 'Gasthof Post', ownerIndex: 1, stopType: 'hotel', lat: 48, lng: 12 }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
const disc = (row: HTMLElement) => row.querySelector('svg.lucide-bed-double')!.parentElement as HTMLElement
const hotelStop = disc(screen.getByText('Gasthof Post').closest('div.grid') as HTMLElement)
const edge = disc(screen.getByText('Check-out').closest('button')!)
expect(edge.style.background).not.toBe('')
expect(edge.style.background).toBe(hotelStop.style.background)
expect(edge.style.color).toBe(hotelStop.style.color)
expect(edge.className).not.toContain('bg-surface-tertiary')
})
it('FE-ROADTRIP-SIDEBAR-082: the own stop of a stay on its check-in day carries the check-in hour as a badge', () => {
const stops = [
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
stop({ assignmentId: 2, name: 'Gasthof Post', ownerIndex: 1, stopType: 'hotel', night: true, checkInTime: '15:00' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg()] })] })} />)
const hotel = screen.getByText('Gasthof Post').closest('li')!
expect(within(hotel).getByText('Check-in').closest('.flex-wrap')).toHaveTextContent('15:00')
expect(within(screen.getByText('Lookout').closest('li')!).queryByText('Check-in')).toBeNull()
})
})
describe('RoadtripSidebar with a flight the drive gets to late (#2460)', () => {
/** LH 2078 as the routing round seats it: pinned an hour ahead for the check-in, named the way every airport writer names an endpoint. */
const end = (role: 'departure' | 'arrival', type = 'flight') =>
stop({
assignmentId: role === 'departure' ? -3000000780 : -3000000781,
name: role === 'departure' ? 'Hamburg (HAM)' : 'Munich (MUC)',
placeId: -78,
lat: role === 'departure' ? 53.63 : 48.35,
lng: role === 'departure' ? 9.99 : 11.78,
time: role === 'departure' ? '14:15' : '17:20',
dwellMinutes: role === 'departure' ? 60 : 0,
carrier: {
reservationId: 78,
type,
role,
title: 'LH 2078 HAM-MUC (ohne Endpunkte)',
code: role === 'departure' ? 'HAM' : 'MUC',
at: role === 'departure' ? '15:15' : '17:20',
},
})
/** Out of the Hamburg hotel, the flight, into the Munich one, with the drive to the airport `late` minutes past check-in. */
const flightDay = (late: number | null, type = 'flight') => {
const stops = [stop({ assignmentId: 1, name: 'Hotel Atlantic' }), end('departure', type), end('arrival', type), stop({ assignmentId: 2, name: 'Hotel Bayerischer Hof' })]
return day({
dayId: 3,
dayNumber: 3,
stops,
legs: [leg(), leg({ mode: type, distance: 0, duration: 7500, distanceText: '', durationText: '2 h 5 min' }), leg()],
schedule: {
entries: [
{ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 },
{ arrival: '14:15', departure: '15:15', anchored: true, dayOffset: 0 },
{ arrival: '17:20', departure: '17:20', anchored: true, dayOffset: 0 },
{ arrival: '18:00', departure: '18:00', anchored: false, dayOffset: 0 },
],
warnings: late === null ? [] : [{ index: 1, code: 'late' as const, minutes: late }],
},
})
}
const block = () => screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!.children[1] as HTMLElement
it('FE-ROADTRIP-SIDEBAR-075: a missed flight says so on its check-in and on the day, with no delta against a clock nobody sees', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
// The title as typed, the duration beside the check-in, each code once and the places without it.
const title = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)')
expect(title.className).not.toContain('uppercase')
expect(within(block()).getByText('Missed').closest('.flex-wrap')).toHaveTextContent('2 h 5 min')
expect(title.parentElement).not.toHaveTextContent('2 h 5 min')
expect(screen.getAllByText('HAM')).toHaveLength(1)
expect(screen.getByText('Hamburg')).toBeInTheDocument()
expect(screen.getByText('Munich')).toBeInTheDocument()
expect(screen.queryByText(/\(HAM\)/)).toBeNull()
// The check-in carries the finding; the separate late pill is gone.
expect(within(block()).getByText('Missed')).toBeInTheDocument()
expect(within(block()).getByText('there at 23:54')).toBeInTheDocument()
expect(screen.queryByText('+9 h 39 min')).toBeNull()
expect(block().className).toContain('border-warning')
const header = screen.getByText('Day 3').closest('header')!
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-076: a folded day still says its flight is missed', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} collapsedDayIds={new Set([3])} onToggleDay={vi.fn()} />)
const header = screen.getByText('Day 3').closest('header')!
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-077: late for the check-in but in time for the flight says by how much, and leaves the day alone', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(25)], totalStops: 2 })} />)
expect(within(block()).getByText('Check-in')).toBeInTheDocument()
expect(within(block()).getByText('25 min late')).toBeInTheDocument()
expect(block().className).not.toContain('border-warning')
expect(screen.queryByText('Flight missed')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-078: a train is boarded, and a missed one is named as a train', () => {
const onTime = wrap(<RoadtripSidebar routes={routes({ days: [flightDay(null, 'train')], totalStops: 2 })} />)
expect(screen.getByText('Boarding')).toBeInTheDocument()
expect(screen.getByText('14:15')).toBeInTheDocument()
onTime.unmount()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579, 'train')], totalStops: 2 })} />)
expect(within(screen.getByText('Day 3').closest('header')!).getByText('Train missed')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-079: the badge explains itself in a sentence, and a booking clock names what it is, not a time you set', () => {
vi.useFakeTimers()
try {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} />)
const hover = (el: HTMLElement) => {
fireEvent.mouseEnter(el)
act(() => { vi.advanceTimersByTime(300) })
}
hover(screen.getByText('there at 23:54').parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent(
'The drive reaches Hamburg at 23:54. LH 2078 HAM-MUC (ohne Endpunkte) departs at 15:15, check-in closes at 14:15.',
)
fireEvent.mouseLeave(screen.getByText('there at 23:54').parentElement!)
hover(screen.getByText('15:15').parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent('Departure 15:15')
expect(screen.queryByText('Time you set')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('FE-ROADTRIP-SIDEBAR-080: a screen reader hears the ride, its clocks and that it is missed, then what pressing it does', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
const button = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!
expect(button).not.toHaveAttribute('aria-label')
expect(button).toHaveAccessibleName(expect.stringMatching(/Departure 15:15\s*Arrival 17:20.*Missed\s*there at 23:54\s*2 h 5 min\s*Open booking$/))
})
})
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest'
import { alternativeSubline, alternativesBusy, alternativesPhase, buildAlternativeOverlays, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
import { alternativeSubline, alternativesPhase, buildAlternativeOverlays, 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..023: turning the router's answers into something drawable.
* FE-ALTOVL-001..017: 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
@@ -221,56 +221,6 @@ describe('buildAlternativeOverlays', () => {
expect(out[0].note).toBe('Current')
expect(out[1].color).toBe(ALT_SECONDARY)
})
it('FE-ALTOVL-018: the blue belongs to the road being driven, never to the quickest offer', () => {
// The picker always lists the rail's road as the current one. After a choice took, the
// old road came back as the router's quickest and was painted blue as if still driven.
const out = buildAlternativeOverlays(
[
alt({ duration: 11_400, coordinates: line(20, 0), current: true }),
alt({ duration: 10_800, coordinates: line(20, 1) }),
alt({ duration: 12_000, coordinates: line(20, 2) }),
],
LABELS,
)
expect(out.map(o => o.color)).toEqual([ALT_PRIMARY, ALT_SECONDARY, ALT_SECONDARY])
expect(out.map(o => o.note)).toEqual(['Current', 'Fastest', ''])
// Current takes part in the comparison: it is the rail's engine's own figure.
expect(out[0].slowerThanQuickest).toBe(600)
})
it('FE-ALTOVL-019: on a leg the second engine drives, its offers are the comparable ones', () => {
// A trip that avoids something is routed by Valhalla, rail and offers alike. Read
// against OSRM they would all be marked as another engine's and none could be timed.
const out = buildAlternativeOverlays(
[
alt({ duration: 6_778, coordinates: line(20, 0), current: true, engine: 'valhalla' }),
alt({ duration: 6_400, coordinates: line(20, 1), engine: 'valhalla' }),
alt({ duration: 5_600, coordinates: line(20, 2) }),
],
LABELS,
'valhalla',
)
expect(out.map(o => o.otherEngine)).toEqual([false, false, true])
// The quickest is elected among Valhalla's figures, so the OSRM one cannot take it.
expect(out[1].note).toBe('Fastest')
expect(out[0].slowerThanQuickest).toBe(378)
expect(out[2].slowerThanQuickest).toBe(0)
})
it('FE-ALTOVL-020: on a leg a plugin prices, every router offer is read as another engine', () => {
const out = buildAlternativeOverlays(
[
alt({ duration: 9_000, coordinates: line(20, 0), current: true, engine: 'plugin' }),
alt({ duration: 7_200, coordinates: line(20, 1) }),
],
LABELS,
'plugin',
)
expect(out.map(o => o.otherEngine)).toEqual([false, true])
expect(out[1].note).toBe('')
expect(out.every(o => o.slowerThanQuickest === 0)).toBe(true)
})
})
describe('alternativesPhase', () => {
@@ -303,60 +253,5 @@ describe('alternativeSubline', () => {
// The caller words the difference; the figure is the same short duration the map prints.
expect(alternativeSubline(overlay({ note: '', slowerThanQuickest: 1800 }), slower))
.toBe(`${formatDurationShort(1800)} slower`)
// Another engine's road has no difference to print, only its own time.
expect(alternativeSubline(overlay({ note: '', otherEngine: true, label: '2 h', slowerThanQuickest: 0 }), slower))
.toBe('2 h')
})
})
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)
expect(alternativesBusy(undefined)).toBe(false)
expect(alternativesBusy({ proving: null })).toBe(false)
// Index 0 is a real offer, not "nothing".
expect(alternativesBusy({ proving: 0 })).toBe(true)
expect(alternativesBusy({ proving: 2 })).toBe(true)
})
})
@@ -1,6 +1,6 @@
import { formatDurationShort } from './roadtripModel'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative, RouteEngine } from '../Map/RouteCalculator'
import type { RouteAlternative } from '../Map/RouteCalculator'
import type { LegAlternatives } from './useRouteAlternatives'
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
@@ -24,8 +24,6 @@ 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 }
@@ -90,35 +88,29 @@ function mostDistinctPoint(
* Each label hangs where its own route is furthest from all the others, so a label always
* sits on a stretch only that route uses. Two routes that share their first and last
* thirds still get their labels on the middle third, where they actually differ.
*
* `railEngine` is the engine the rail drives this leg with. Only routes that engine
* priced can be read against the road being driven; every other one is marked as the
* other engine's and takes part in no comparison.
*/
export function buildAlternativeOverlays(
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
railEngine: RouteEngine = 'osrm',
): AlternativeOverlay[] {
if (!routes?.length) return []
// A single answer is not a choice; drawing it would just double the route already there.
if (routes.length < 2) return []
// The quickest of what came back, which is what Apple calls out — not necessarily the
// first entry, since the road currently driven is put at the top.
// first entry, since the road currently driven is put at the top when there is one.
//
// Only among the routes the rail's own engine priced, the current road included. An
// offer from another engine runs on a speed model that differs by up to a seventh
// either way depending on the region, so letting it into this election decides which
// road is fastest and how much slower every other road is called on nothing but that
// gap. On a Spanish motorway leg that is enough to crown a toll-free B-road detour and
// label the motorway somebody is actually driving as the slower way round.
// Only among the routes one engine priced. The avoidance offer on a default install
// comes from the second engine, whose speed model differs by up to a seventh either
// way depending on the region, so letting it into this election decides which road is
// blue and how much slower every other road is called on nothing but that gap. On a
// Spanish motorway leg that is enough to crown a toll-free B-road detour and label
// the motorway somebody is actually driving as the slower way round.
//
// Index 0 is the fallback rather than -1: the first entry is the road being driven
// when there is one and the router's own pick otherwise, so if a list ever held
// nothing the rail's engine priced, that is still the one to call primary.
const engineOf = (route: RouteAlternative): RouteEngine => route.engine ?? 'osrm'
const comparable = routes.map((r, i) => (engineOf(r) === railEngine ? i : -1)).filter(i => i >= 0)
// nothing but second-engine routes, that is still the one to call primary.
const comparable = routes.map((r, i) => (r.engine ? -1 : i)).filter(i => i >= 0)
const quickest = comparable.length
? comparable.reduce((best, i) => (routes[i].duration < routes[best].duration ? i : best), comparable[0])
: 0
@@ -127,11 +119,10 @@ export function buildAlternativeOverlays(
return routes.map((route, index) => {
const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
const at = mostDistinctPoint(route.coordinates, others)
// Blue is the road you are on, which the picker always lists as the current one,
// however slow it is. Only a list without it (none is built that way any more) falls
// back to the quickest. Everything else is the pale blue of an offer.
const primary = anyCurrent ? !!route.current : index === quickest
const otherEngine = engineOf(route) !== railEngine
// Blue is the road you are on: the one currently driven, or the router's own pick
// when nothing has been bent. Everything else is the pale blue of an offer.
const primary = route.current || (!anyCurrent && index === quickest)
const otherEngine = !!route.engine
return {
index,
coordinates: route.coordinates,
@@ -155,11 +146,10 @@ export function buildAlternativeOverlays(
// that is the only thing to measure against. The bar reads a zero as "no
// difference worth printing" and falls back to naming what the road is, which
// for these is always the class left out of it.
slowerThanQuickest: otherEngine || engineOf(routes[quickest]) !== railEngine
slowerThanQuickest: otherEngine || !!routes[quickest].engine
? 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 },
}
@@ -199,33 +189,5 @@ export function alternativesPhase(
* instead of each keeping its own.
*/
export function alternativeSubline(alt: AlternativeOverlay, slower: (time: string) => string): string {
if (alt.note) return alt.note
// Another engine's road has no difference to the quickest, only its own time: a "0 min
// slower" under it would read as a tie that nobody measured.
if (alt.otherEngine) return alt.label
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.
*/
export function alternativesBusy(open: Pick<LegAlternatives, 'proving'> | null | undefined): boolean {
return open?.proving != null
return alt.note || slower(formatDurationShort(alt.slowerThanQuickest))
}
@@ -1,218 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
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..013: 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
* pulls the point to the pier and drives through Calais), and on a way weighed away from
* motorways it kept the motorway after the one point. These cases pin down how the pins
* are found, how many, in which order, and when there is no verdict at all.
*/
/** The road being driven, and a detour that leaves it twice before rejoining. */
const RAIL: [number, number][] = [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]]
const DETOUR: [number, number][] = [[53.55, 9.99], [53.4, 11.5], [53.3, 12.8], [52.52, 13.4]]
const offer = (over: Partial<OfferedRoute> = {}): OfferedRoute =>
({ coordinates: DETOUR, distance: 310_000, duration: 11_400, divergence: null, ...over })
const current = { coordinates: RAIL, distance: 290_000, duration: 10_800 }
const answer = (coordinates: [number, number][], over: Partial<RailLegRoute> = {}): RailLegRoute =>
({ coordinates, distance: 300_000, duration: 11_000, ...over })
const signal = () => new AbortController().signal
type Route = RailLegRouter['route']
describe('pinAlternative', () => {
it('FE-ALTPIN-001: the router own preference is asked with no pin at all', async () => {
const route = vi.fn<Route>(async () => answer(DETOUR))
const proof = await pinAlternative({ offer: offer({ direct: true }), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(1)
expect(route.mock.calls[0][0]).toEqual([])
expect(proof).toMatchObject({ held: true, pins: [], fellBack: false })
})
it('FE-ALTPIN-002: any other offer starts from the point where it leaves the driven road furthest', async () => {
const route = vi.fn<Route>(async () => answer(DETOUR))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(1)
expect(proof).toMatchObject({ held: true, pins: [{ lat: 53.3, lng: 12.8 }] })
})
it('FE-ALTPIN-003: an answer that is not the offer adds the point it missed, in the order the offer drives', async () => {
// The first answer passes the pin and still takes the old road for its first half.
const route = vi.fn<Route>()
.mockResolvedValueOnce(answer([[53.55, 9.99], [53.0, 11.5], [53.3, 12.8], [52.52, 13.4]]))
.mockResolvedValueOnce(answer(DETOUR))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(2)
// Driven through backwards the leg would double back between them.
expect(route.mock.calls[1][0]).toEqual([{ lat: 53.4, lng: 11.5 }, { lat: 53.3, lng: 12.8 }])
expect(proof.pins).toEqual([{ lat: 53.4, lng: 11.5 }, { lat: 53.3, lng: 12.8 }])
expect(proof.held).toBe(true)
})
it('FE-ALTPIN-004: no more than three pins are tried before the offer counts as not held', async () => {
// A long detour the router leaves somewhere new on every answer.
const long: [number, number][] = [[50, 0], [50.5, 1], [51, 2], [51.5, 3], [52, 4], [52.5, 5], [52, 6]]
const answers: [number, number][][] = [
[[50, 0], [50, 1], [51, 2], [51.5, 3], [52, 4], [52.5, 5], [52, 6]],
[[50, 0], [50.5, 1], [51, 2], [51, 3], [52, 4], [52.5, 5], [52, 6]],
[[50, 0], [50.5, 1], [51, 2], [51.5, 3], [52, 4], [52, 5], [52, 6]],
]
const route = vi.fn<Route>()
for (const line of answers) route.mockResolvedValueOnce(answer(line))
const proof = await pinAlternative({ offer: offer({ coordinates: long }), current: { ...current, coordinates: [[50, 0], [52, 6]] }, route, signal: signal() })
expect(MAX_PINS).toBe(3)
expect(route).toHaveBeenCalledTimes(3)
expect(route.mock.calls[2][0]).toHaveLength(3)
expect(proof).toMatchObject({ held: false, pins: [], fellBack: false })
expect(proof.last.coordinates).toEqual(answers[2])
})
it('FE-ALTPIN-005: an answer that misses the same point again ends the check rather than repeating it', async () => {
// A point OSRM pulls off a ferry to the pier: pinned again, the same road comes back.
const route = vi.fn<Route>(async () => answer(RAIL))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(1)
expect(proof).toMatchObject({ held: false, fellBack: false })
expect(proof.last.coordinates).toEqual(RAIL)
})
it('FE-ALTPIN-006: an answer from the stand-in engine gives no verdict on the road', async () => {
// It would prove the offer to an engine that will not be the one drawing the leg.
const route = vi.fn<Route>(async () => answer(DETOUR, { fellBack: true }))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(proof).toMatchObject({ held: false, fellBack: true, pins: [] })
})
it('FE-ALTPIN-007: with no road to measure against, the check starts unpinned', async () => {
const route = vi.fn<Route>(async () => answer(DETOUR))
await pinAlternative({ offer: offer(), current: undefined, route, signal: signal() })
expect(route.mock.calls[0][0]).toEqual([])
})
it('FE-ALTPIN-008: an abandoned check asks the router nothing more', async () => {
const controller = new AbortController()
const route = vi.fn<Route>(async () => {
controller.abort()
return answer([[53.55, 9.99], [53.0, 11.5], [53.3, 12.8], [52.52, 13.4]])
})
await expect(pinAlternative({ offer: offer(), current, route, signal: controller.signal }))
.rejects.toMatchObject({ name: 'AbortError' })
expect(route).toHaveBeenCalledTimes(1)
})
})
describe('railLegAt', () => {
const stop = (assignmentId: number, ownerDayId: number, ownerIndex: number, over: Partial<RoadtripStop> = {}) =>
({ assignmentId, ownerDayId, ownerIndex, lat: 50, lng: 10, ...over }) as RoadtripStop
const day = (dayId: number, stops: RoadtripStop[]) => ({ dayId, stops }) as RoadtripDay
it('FE-ALTPIN-009: finds the leg leaving the stop filed at the anchor, inside a card or across to the next', () => {
const days = [
day(1, [stop(10, 1, 0), stop(11, 1, 1)]),
// A card that opens on a stop stored the day before, and a night marker standing on
// the same position as a real stop.
day(2, [stop(12, 1, 2), stop(12, 1, 2, { automaticNight: { phase: 'start' } as RoadtripStop['automaticNight'] }), stop(20, 2, 0)]),
]
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toMatchObject({ from: { assignmentId: 11 }, to: { assignmentId: 12 } })
expect(railLegAt(days, { dayId: 1, afterIndex: 2 })).toMatchObject({ from: { assignmentId: 12 }, to: { assignmentId: 20 } })
})
it('FE-ALTPIN-010: a terminal borrowing the index is no answer, and an anchor with nothing after it is none', () => {
const terminal = stop(-99, 1, 0, { carrier: { role: 'departure' } as RoadtripStop['carrier'] })
const days = [day(1, [terminal, stop(10, 1, 0), stop(11, 1, 1)])]
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toBeNull()
expect(railLegAt(days, { dayId: 3, afterIndex: 0 })).toBeNull()
})
it('FE-ALTPIN-013: neither is the hotel a day sets out from, so a choice on the first leg is still that leg', () => {
// The morning's hotel borrows the first stop's index. Answering for it, the leg read
// back was hotel to first stop, and every choice on leg 0 came back as changed.
const bookend = (id: number, ownerIndex: number, phase: 'morning' | 'evening') => stop(id, 1, ownerIndex, {
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
})
const days = [day(1, [bookend(-6_000_000_002, 0, 'morning'), stop(10, 1, 0), stop(11, 1, 1), bookend(-6_000_000_003, 2, 'evening')])]
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
// Behind the last stop the rail drives to tonight's hotel, and that is what it reports.
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toMatchObject({ from: { assignmentId: 11 }, to: { assignmentId: -6_000_000_003 } })
})
})
describe('railDriveOn', () => {
const stop = (assignmentId: number, ownerDayId: number, ownerIndex: number) =>
({ assignmentId, ownerDayId, ownerIndex, lat: 50, lng: 10 }) as RoadtripStop
const seg = (distance: number) => ({ distance, duration: distance / 20 }) as RoadtripDay['legs'][number]
const LEG_LINE: [number, number][] = [[50, 10], [50.2, 10.4]]
const DRIVE_IN: [number, number][] = [[51, 9], [50, 10]]
const card = (over: Partial<RoadtripDay> = {}) => ({
dayId: 2,
stops: [stop(20, 2, 0), stop(21, 2, 1)],
legs: [seg(40_000)],
legLines: [LEG_LINE],
arrivingFrom: stop(12, 1, 2),
arrivingLeg: seg(90_000),
arrivingLine: DRIVE_IN,
...over,
}) as RoadtripDay
it('FE-ALTPIN-011: a leg is read off its index, the drive in off the stop the day before ended on', () => {
expect(railDriveOn(card(), { kind: 'leg', index: 0 })).toMatchObject({
from: { assignmentId: 20 }, to: { assignmentId: 21 }, seg: { distance: 40_000 }, line: LEG_LINE,
})
expect(railDriveOn(card(), { kind: 'arriving' })).toMatchObject({
from: { assignmentId: 12, ownerDayId: 1, ownerIndex: 2 }, to: { assignmentId: 20 }, seg: { distance: 90_000 }, line: DRIVE_IN,
})
// Nothing past the last stop, no drive in on a card nothing joins, and no road yet.
expect(railDriveOn(card(), { kind: 'leg', index: 1 })).toBeNull()
expect(railDriveOn(card({ arrivingFrom: undefined, arrivingLeg: undefined }), { kind: 'arriving' })).toBeNull()
expect(railDriveOn(card({ legs: [] }), { kind: 'leg', index: 0 })).toBeNull()
// A line the card does not have yet is no reason to refuse the drive.
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()
})
})
@@ -1,147 +0,0 @@
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives'
/**
* The most points one choice may pin to the road.
*
* Every pin is one more request to a host that allows one a second, and one more handle on
* the map somebody has to understand later. A way that three points cannot hold is not
* held by the router at all: a ferry OSRM will not board, or a class the day is not
* weighed against.
*/
export const MAX_PINS = 3
/** A point a choice is held by, in the order the chosen road drives through them. */
export interface Pin {
lat: number
lng: number
}
/** Whether the rail's router drives an offer, and the pins that make it do so. */
export interface PinProof {
/** True when the router drove the offer with `pins` in place. */
held: boolean
/** The pins that held it, in the order the offer drives through them. Empty otherwise. */
pins: Pin[]
/** True when the last answer came from a stand-in engine, which proves nothing either way. */
fellBack: boolean
/** The router's answer to the last attempt. */
last: RailLegRoute
}
/**
* The fewest points that make the rail's own router drive `offer`, found by asking it.
*
* A choice used to be stored as one via at the point where the offer strayed furthest,
* and nobody asked whether the router then drove the offer. Often it did not. A point on
* a ferry is pulled to the nearest pier by OSRM, which never boards one, so Amsterdam to
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. An
* offer weighed away from motorways by the second engine is held by one point for a few
* kilometres, and the motorway takes the rest. Either way the choice looked saved and the
* drive did something else.
*
* So the check is the router itself. The router's own preference starts with no pin; any
* other offer with the point furthest from the road being driven. After each answer that
* is not the offer, the point of the offer furthest from that answer joins in, placed in
* the order the offer drives through them, until the answer is the offer or `maxPins` is
* reached. The spacing between the requests is the router's own (`RailLegRouter.route`).
*
* An answer from a stand-in engine proves nothing about the one that will draw the leg,
* so it ends the check without a verdict on the road.
*/
export async function pinAlternative({
offer,
current,
route,
signal,
maxPins = MAX_PINS,
}: {
offer: OfferedRoute
/** The road the rail drives now; the first pin is where the offer leaves it furthest. */
current: RoadLine | undefined
route: RailLegRouter['route']
signal: AbortSignal
maxPins?: number
}): Promise<PinProof> {
const placed: { index: number; lat: number; lng: number }[] = []
if (!offer.direct) {
const far = furthestFrom(offer.coordinates, current?.coordinates ?? [])
if (far) placed.push(far)
}
for (;;) {
if (signal.aborted) throw new DOMException('The check was abandoned', 'AbortError')
const last = await route(placed.map(({ lat, lng }) => ({ lat, lng })), signal)
if (last.fellBack) return { held: false, pins: [], fellBack: true, last }
if (sameRoad(last, offer)) return { held: true, pins: placed.map(({ lat, lng }) => ({ lat, lng })), fellBack: false, last }
if (placed.length >= maxPins) return { held: false, pins: [], fellBack: false, last }
const far = furthestFrom(offer.coordinates, last.coordinates)
// The same point again would ask the same question and earn the same answer.
if (!far || placed.some(p => p.index === far.index)) return { held: false, pins: [], fellBack: false, last }
placed.push(far)
placed.sort((a, b) => a.index - b.index)
}
}
/**
* 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.
*
* Read across every day in order, so the stop after it is found whether the leg runs
* inside one card or across to the next. The caller hands in the days with a single stop
* too: they draw no card, but the drive into the day after one leaves from its stop. The
* markers an automatic night puts on the chain stand on or between stops without being
* one, and a terminal or a booked night at a day's edge borrows the index of a stored stop
* to be seated by, so none of them can answer for the anchor.
*/
export function railLegAt(days: readonly Pick<RoadtripDay, 'stops'>[], anchor: ViaAnchor): { from: RoadtripStop; to: RoadtripStop } | null {
const chain = days.flatMap(day => day.stops).filter(stop => !stop.automaticNight)
const at = chain.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
const to = at >= 0 ? chain[at + 1] : undefined
return to ? { from: chain[at], to } : null
}
/** One drive on a card as the rail has it: its two stops, its road and the line it is drawn on. */
export interface RailDriveOnCard {
from: RoadtripStop
to: RoadtripStop
seg: RouteSegment
line: [number, number][] | undefined
}
/**
* The drive `drive` names on `day`, or null while the card has no such drive or no road
* for it yet.
*
* A leg runs from stop `index` to the next one. The drive arriving at the head of a
* connected card has no index on it: it leaves the last stop of the day before, which the
* card keeps as `arrivingFrom`, and ends at the card's first stop. Read here once, so the
* picker is handed the same two stops, figures and line whichever of the two it opens on.
*/
export function railDriveOn(day: RoadtripDay, drive: RailDrive): RailDriveOnCard | null {
const found = drive.kind === 'arriving'
? { from: day.arrivingFrom, to: day.stops[0], seg: day.arrivingLeg, line: day.arrivingLine }
: { from: day.stops[drive.index], to: day.stops[drive.index + 1], seg: day.legs[drive.index], line: day.legLines?.[drive.index] }
const { from, to, seg, line } = found
return from && to && seg ? { from, to, seg, line } : null
}
@@ -1,191 +0,0 @@
import { describe, expect, it } from 'vitest'
import { missedRide, rideDuration, rideReading, terminalReading } from './carrierRide'
import type { CarrierTerminal, RoadtripStop, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
// FE-CARRIER-RIDE-001 to FE-CARRIER-RIDE-009
/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */
const t = (key: string, params?: Record<string, string | number | null>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
/**
* One end of LH 2078 as the routing round seats it: the departure pinned an hour ahead of
* the timetable for the check-in and left at the timetable's minute, the arrival pinned at
* its own. Named the way every airport writer names an endpoint.
*/
function terminal(role: CarrierTerminal['role'], over: Partial<CarrierTerminal> = {}, stop: Partial<RoadtripStop> = {}): RoadtripStop {
const departs = role === 'departure'
const carrier: CarrierTerminal = {
reservationId: 78,
type: 'flight',
role,
title: 'LH 2078 HAM-MUC (ohne Endpunkte)',
code: departs ? 'HAM' : 'MUC',
at: departs ? '15:15' : '17:20',
...over,
}
return {
assignmentId: departs ? -780 : -781,
ownerDayId: 3,
ownerIndex: 1,
placeId: -78,
name: departs ? 'Hamburg (HAM)' : 'Munich (MUC)',
lat: departs ? 53.63 : 48.35,
lng: departs ? 9.99 : 11.78,
time: departs ? '14:15' : '17:20',
dwellMinutes: departs ? 60 : 0,
legMode: departs ? carrier.type : null,
incomingLegMode: departs ? null : carrier.type,
stopType: null,
carrier,
...stop,
}
}
const FLIGHT: RouteSegment = {
mid: [50, 10], from: [53.63, 9.99], to: [48.35, 11.78],
distance: 0, duration: 7500, walkingText: '', drivingText: '', distanceText: '', durationText: '2 h 5 min',
mode: 'flight',
}
const late = (minutes: number): ScheduleWarning => ({ index: 1, code: 'late', minutes })
const read = (warnings: ScheduleWarning[] = [], is12h = false, over: Partial<CarrierTerminal> = {}) =>
rideReading({
departure: terminal('departure', over),
arrival: terminal('arrival', over),
entries: [
{ arrival: '14:15', departure: '15:15', anchored: true, dayOffset: 0 },
{ arrival: '17:20', departure: '17:20', anchored: true, dayOffset: 0 },
],
warnings,
seg: FLIGHT,
}, t, is12h)
describe('a ride as its block reads it', () => {
it('FE-CARRIER-RIDE-001: every fact once, the title as typed, the code off the place, and the check-in with its hour', () => {
const ride = read()
expect(ride.title).toBe('LH 2078 HAM-MUC (ohne Endpunkte)')
expect(ride.duration).toBe('2 h 5 min')
expect(ride.from).toEqual({
code: 'HAM',
place: 'Hamburg',
chip: 'HAM',
placeLine: 'Hamburg',
clock: '15:15',
dayOffset: 0,
label: 'roadtrip.ride.departureFlight(time=15:15)',
})
expect(ride.to).toMatchObject({ chip: 'MUC', placeLine: 'Munich', clock: '17:20', label: 'roadtrip.ride.arrival(time=17:20)' })
expect(ride.checkIn).toEqual({
state: 'ok',
lead: 'roadtrip.ride.checkIn',
value: '14:15',
hint: 'roadtrip.ride.hintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15)',
})
})
it('FE-CARRIER-RIDE-002: reached after the check-in but before take-off is tight, and says by how much', () => {
const checkIn = read([late(25)]).checkIn!
expect(checkIn.state).toBe('tight')
expect(checkIn.lead).toBe('roadtrip.ride.checkIn')
expect(checkIn.value).toBe('roadtrip.ride.lateBy(time=25 min)')
expect(checkIn.hint).toBe(
'roadtrip.ride.lateHintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15,place=Hamburg,time=14:40)',
)
// Reaching the gate as the plane leaves is still a catch.
expect(read([late(60)]).checkIn!.state).toBe('tight')
})
it('FE-CARRIER-RIDE-003: reached after take-off is missed, and says when the drive gets there instead of a delta', () => {
const checkIn = read([late(579)]).checkIn!
expect(checkIn.state).toBe('missed')
expect(checkIn.lead).toBe('roadtrip.ride.missed')
expect(checkIn.value).toBe('roadtrip.ride.reachedAt(time=23:54)')
expect(checkIn.hint).toContain('place=Hamburg,time=23:54')
// Past midnight the clock carries its day the way the rail's arrivals do, in the reader's clock.
expect(read([late(12 * 60 + 15)], true).checkIn!.value).toBe('roadtrip.ride.reachedAt(time=2:30 AM+1)')
// Findings that are not about being late leave the check-in alone.
expect(read([{ index: 1, code: 'leg', overMinutes: 30 }]).checkIn!.state).toBe('ok')
})
it('FE-CARRIER-RIDE-004: a train or a bus is boarded, a ferry or a cruise sails, and only a flight has its own departure word', () => {
const train = read([], false, { type: 'train', code: null })
expect(train.checkIn!.lead).toBe('roadtrip.ride.boarding')
expect(train.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintBoarding\(/)
expect(train.from.label).toBe('roadtrip.ride.departure(time=15:15)')
expect(read([late(25)], false, { type: 'bus' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.lateHintBoarding\(/)
const ferry = read([], false, { type: 'ferry' })
expect(ferry.checkIn!.lead).toBe('roadtrip.ride.checkIn')
expect(ferry.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/)
expect(read([], false, { type: 'cruise' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/)
})
it('FE-CARRIER-RIDE-005: a terminal without a code puts its name in the chip, and one without a timetable names no clock and no check-in', () => {
const station = rideReading({
departure: terminal('departure', { type: 'train', code: null, at: null }, { name: 'Hamburg Hbf', time: null }),
arrival: terminal('arrival', { type: 'train', code: null, at: null }, { name: 'München Hbf', time: null }),
entries: [undefined, { arrival: '07:20', departure: '07:20', anchored: false, dayOffset: 1 }],
warnings: [],
seg: undefined,
}, t, false)
expect(station.from).toMatchObject({ code: null, chip: 'Hamburg Hbf', placeLine: null, clock: null, label: null, dayOffset: 0 })
expect(station.to).toMatchObject({ chip: 'München Hbf', dayOffset: 1 })
expect(station.duration).toBeNull()
expect(station.checkIn).toBeNull()
})
})
describe('a terminal on a row of its own', () => {
it('FE-CARRIER-RIDE-006: a desk says which one it is and has no check-in; a lone departure has one, a lone arrival has none', () => {
const pickup = terminalReading(
terminal('pickup', { type: 'car', title: 'Sixt Hamburg', code: null, at: '09:00' }, { name: 'Sixt Hauptbahnhof', time: '09:00' }),
undefined,
[],
t,
false,
)
expect(pickup.desk).toBe('reservations.span.pickup')
expect(pickup.checkIn).toBeNull()
expect(pickup.end).toMatchObject({ place: 'Sixt Hauptbahnhof', clock: '09:00', label: 'roadtrip.ride.pickup(time=09:00)' })
const leaving = terminalReading(terminal('departure'), undefined, [late(579)], t, false)
expect(leaving.desk).toBeNull()
expect(leaving.checkIn!.state).toBe('missed')
expect(terminalReading(terminal('arrival'), undefined, [late(30)], t, false).checkIn).toBeNull()
})
})
describe('the day a ride is missed on', () => {
const day = (warnings: ScheduleWarning[], type = 'flight') => ({
stops: [
{ ...terminal('pickup', { type: 'car', code: null }), carrier: undefined, name: 'Hotel Atlantic' },
terminal('departure', { type }),
terminal('arrival', { type }),
],
schedule: { entries: [], warnings },
})
it('FE-CARRIER-RIDE-007: names the kind of ride in the header and keeps the sentence for its tooltip', () => {
expect(missedRide(day([late(579)]), t, false)).toEqual({
label: 'roadtrip.day.rideMissed.flight',
hint: expect.stringContaining('time=23:54'),
})
expect(missedRide(day([late(579)], 'ferry'), t, false)!.label).toBe('roadtrip.day.rideMissed.ferry')
})
it('FE-CARRIER-RIDE-008: a ride caught, late or not, and a finding at another stop say nothing in the header', () => {
expect(missedRide(day([]), t, false)).toBeNull()
expect(missedRide(day([late(25)]), t, false)).toBeNull()
expect(missedRide(day([{ index: 2, code: 'late', minutes: 600 }]), t, false)).toBeNull()
})
})
describe('rideDuration', () => {
it('FE-CARRIER-RIDE-009: the leg\'s own text, else its seconds, else nothing', () => {
expect(rideDuration(FLIGHT)).toBe('2 h 5 min')
expect(rideDuration({ ...FLIGHT, durationText: '' })).toBe('2 h 5 min')
expect(rideDuration({ ...FLIGHT, durationText: '', duration: 0 })).toBeNull()
expect(rideDuration(undefined)).toBeNull()
})
})
+21 -201
View File
@@ -1,18 +1,15 @@
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
import { formatClock, formatDurationShort, parseClock, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import { formatDurationShort } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters'
import { stripAirportCode } from '../../utils/flightLegs'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { CARRIER_TYPES, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { Reservation, TranslationFn } from '../../types'
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../types'
/**
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
* the phone chain (#2428), and which of the bookings under a stop open there.
* the phone chain (#2428).
*
* One module for both shells, because the two would otherwise carry the same icon table,
* the same reading of the timetable against the drive and the same sentences each, and the
* duplication budget does not stretch to that. The shells only draw what comes out of here.
* One module for both shells, because the two would otherwise carry the same icon table
* and the same three sentences each, and the duplication budget does not stretch to that.
* The icons are the booking panel's for the same types, so a flight looks like a flight
* wherever the trip shows it.
*/
@@ -30,198 +27,21 @@ export function carrierIcon(type: string): LucideIcon {
}
/**
* How long the ride takes on the timetable: the leg's own text, or its seconds when the
* text is not in yet. Null for a ride the timetable gives no minutes for.
* The line under a terminal's name: the timetable's clock at it, in the reader's own
* clock format. Nothing when the booking names no time.
*/
export function rideDuration(seg: RouteSegment | undefined): string | null {
export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null {
if (!carrier.at) return null
const time = formatClockTime(carrier.at, is12h)
return t(`roadtrip.ride.${carrier.role}`, { time })
}
/**
* What the ride's pill says: the booking, then how long it takes when the timetable gives
* both ends. A ride with no minutes is still the booking, and says so without a duration.
*/
export function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string {
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
return seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : null)
}
/** One end of a ride, or a hire car's desk, as a row or the route strip of a block reads it. */
export interface RideEnd {
/** IATA or station code, when the booking carries one. */
code: string | null
/** The terminal's name without the code the airport forms append to it. */
place: string
/** What the route strip's chip holds: the code, or the name of a terminal without one. */
chip: string
/** The name under the chip, or null when the chip already is the name. */
placeLine: string | null
/** The timetable's clock here, in the reader's format. Null when the booking names none. */
clock: string | null
/** Days past the card's own that the clock falls on, for the timetable's "+1". */
dayOffset: number
/** The clock in words, "Departure 15:15", for its tooltip and a screen reader. */
label: string | null
}
/**
* The departure measured against the drive that gets there.
*
* `ok` is the drive arriving by the check-in, `tight` after it but still before the ride
* leaves, `missed` after it has left. The ride keeps its day either way (`scheduleStopOf`
* pins it `dated`), so a drive a day late reads as missed rather than as tomorrow's ride.
*/
export type RideState = 'ok' | 'tight' | 'missed'
export interface CheckInReading {
state: RideState
/** The badge's word: check-in, boarding for a train or a bus, or that the ride is missed. */
lead: string
/** Its figure: the check-in clock, how late the drive gets there, or when it does. */
value: string
/** The sentence behind the badge, with the timetable in it. */
hint: string
}
export interface RideReading {
title: string
duration: string | null
from: RideEnd
to: RideEnd
checkIn: CheckInReading | null
}
export interface TerminalReading {
end: RideEnd
/** A hire car's desk says which one it is, having no other end beside it to say it. */
desk: string | null
checkIn: CheckInReading | null
}
const DAY_MINUTES = 24 * 60
/** Trains and buses are boarded, the rest is checked in for (`CHECK_IN_MINUTES`). */
const BOARDED = new Set(['train', 'bus'])
const SAILS = new Set(['ferry', 'cruise'])
/** Which of the three timetable sentences a ride takes: they differ in the verb and the word for the check-in. */
function sentenceOf(type: string): 'Flight' | 'Ship' | 'Boarding' {
if (BOARDED.has(type)) return 'Boarding'
return SAILS.has(type) ? 'Ship' : 'Flight'
}
/** Minutes from the card's midnight as a clock, in the timetable's "+1" form past it. */
function clockOf(minutes: number, is12h: boolean): string {
const days = Math.floor(minutes / DAY_MINUTES)
const clock = formatClockTime(formatClock(minutes), is12h)
return days > 0 ? `${clock}+${days}` : clock
}
function rideEnd(stop: RoadtripStop, entry: ScheduleEntry | undefined, t: TranslationFn, is12h: boolean): RideEnd {
const carrier = stop.carrier!
const place = stripAirportCode(stop.name)
const clock = carrier.at ? formatClockTime(carrier.at, is12h) : null
// German, among others, has a word of its own for a plane leaving.
const key = carrier.role === 'departure' && carrier.type === 'flight' ? 'roadtrip.ride.departureFlight' : `roadtrip.ride.${carrier.role}`
return {
code: carrier.code,
place,
chip: carrier.code ?? place,
placeLine: carrier.code && carrier.code !== place ? place : null,
clock,
dayOffset: entry?.dayOffset ?? 0,
label: clock ? t(key, { time: clock }) : null,
}
}
/**
* The check-in against the drive, read off the departure's pin and the schedule's `late`
* finding at it: the pin is the timetable minus the check-in (`terminalStop`), and the
* finding is how far past the pin the chain gets there.
*/
function checkInOf(
departure: RoadtripStop,
warnings: readonly ScheduleWarning[],
t: TranslationFn,
is12h: boolean,
): CheckInReading | null {
const carrier = departure.carrier
if (carrier?.role !== 'departure') return null
const departs = parseClock(carrier.at)
const pinned = parseClock(departure.time)
if (departs === null || pinned === null) return null
const late = warnings.find(w => w.code === 'late')
const sentence = sentenceOf(carrier.type)
const word = t(sentence === 'Boarding' ? 'roadtrip.ride.boarding' : 'roadtrip.ride.checkIn')
const pin = clockOf(pinned, is12h)
const facts = { title: carrier.title, departs: clockOf(departs, is12h), pin }
if (!late) return { state: 'ok', lead: word, value: pin, hint: t(`roadtrip.ride.hint${sentence}`, facts) }
const reach = pinned + (late.minutes ?? 0)
const time = clockOf(reach, is12h)
const hint = t(`roadtrip.ride.lateHint${sentence}`, { ...facts, place: stripAirportCode(departure.name), time })
if (reach > departs) return { state: 'missed', lead: t('roadtrip.ride.missed'), value: t('roadtrip.ride.reachedAt', { time }), hint }
return { state: 'tight', lead: word, value: t('roadtrip.ride.lateBy', { time: formatDurationShort((reach - pinned) * 60) }), hint }
}
/**
* A ride that leaves and lands on the same day, as its block reads it: the booking, its
* minutes, both ends and the check-in with whatever the drive makes of it.
*/
export function rideReading(
ride: {
departure: RoadtripStop
arrival: RoadtripStop
entries: readonly [ScheduleEntry | undefined, ScheduleEntry | undefined]
/** The schedule's findings at the departure. */
warnings: readonly ScheduleWarning[]
seg: RouteSegment | undefined
},
t: TranslationFn,
is12h: boolean,
): RideReading {
return {
title: ride.departure.carrier!.title,
duration: rideDuration(ride.seg),
from: rideEnd(ride.departure, ride.entries[0], t, is12h),
to: rideEnd(ride.arrival, ride.entries[1], t, is12h),
checkIn: checkInOf(ride.departure, ride.warnings, t, is12h),
}
}
/** A terminal on a row of its own, its ride landing on another day, or a hire car's desk. */
export function terminalReading(
stop: RoadtripStop,
entry: ScheduleEntry | undefined,
warnings: readonly ScheduleWarning[],
t: TranslationFn,
is12h: boolean,
): TerminalReading {
const role = stop.carrier!.role
return {
end: rideEnd(stop, entry, t, is12h),
desk: role === 'pickup' || role === 'return' ? t(`reservations.span.${role}`) : null,
checkIn: checkInOf(stop, warnings, t, is12h),
}
}
/**
* The first ride of the day the drive reaches too late to catch, as the day's header names
* it, so a folded day still says so. Null while every ride is caught.
*/
export function missedRide(
day: Pick<RoadtripDay, 'stops' | 'schedule'>,
t: TranslationFn,
is12h: boolean,
): { label: string; hint: string } | null {
for (let i = 0; i < day.stops.length; i++) {
const stop = day.stops[i]!
const checkIn = checkInOf(stop, day.schedule.warnings.filter(w => w.index === i), t, is12h)
if (checkIn?.state !== 'missed') continue
const type = (CARRIER_TYPES as readonly string[]).includes(stop.carrier!.type) ? stop.carrier!.type : 'flight'
return { label: t(`roadtrip.day.rideMissed.${type}`), hint: checkIn.hint }
}
return null
}
/**
* Whether a booking chip opens for this reader. A transport has a detail view anybody
* may look at; a table or a ticket has only its editor, the split the planner page makes
* when it opens one. Without the right to edit bookings the chip stays a plain chip,
* the way the place inspector's booking strip stays inert (#2012).
*/
export function bookingOpens(booking: Pick<Reservation, 'type'>, canEditBookings: boolean): boolean {
return TRANSPORT_TYPES.has(booking.type) || canEditBookings
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
return duration ? `${carrier.title} · ${duration}` : carrier.title
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround, rideGaps, riddenRanges, inRiddenRange, drivenPieces } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit, KmRange, RideGap } from '@trek/shared/roadtrip'
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip'
@@ -1,109 +0,0 @@
import { describe, expect, it } from 'vitest'
import { BedDouble } from 'lucide-react'
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking, leavesAfterCheckOut, staysAtTheirPlaces } from './nightBookend'
import type { BookendReading } from './roadtripRowModel'
import { serviceColor } from './roadtripModel'
// FE-BOOKEND-001 to FE-BOOKEND-010
const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
phase: 'morning',
variant: 'from',
name: 'Hotel Alpenblick',
until: null,
from: null,
reservationId: 41,
accommodationId: 5,
placeId: 900,
...over,
})
/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */
const t = (key: string, params?: Record<string, string | number | null>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
describe('a booked night as the rail and the phone say it', () => {
it('FE-BOOKEND-001: the badge names the edge of the stay the day stands at, the row names the hotel', () => {
expect(bookendBadge(reading({ variant: 'checkOut' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkOut')
expect(bookendBadge(reading({ variant: 'from' }), undefined, t, false).lead).toBe('roadtrip.bookend.from')
expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).lead).toBe('roadtrip.bookend.back')
expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkIn')
})
it('FE-BOOKEND-002: the check-out hour is the figure of the badge, in the clock of the reader', () => {
expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), undefined, t, false))
.toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: false, hint: 'roadtrip.stay.until(time=10:00)' })
expect(bookendBadge(reading({ variant: 'checkOut', until: '14:30' }), undefined, t, true).value).toBe('2:30 PM')
})
it('FE-BOOKEND-003: a morning that hands no room back, or an evening back at the stay, is the word alone', () => {
expect(bookendBadge(reading(), undefined, t, false)).toEqual({ lead: 'roadtrip.bookend.from', value: null, warning: false, hint: null })
expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).value).toBeNull()
})
it('FE-BOOKEND-010: a check-in evening carries the hour the room is ready, and leaving late turns the check-out badge', () => {
expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn', from: '15:00' }), undefined, t, false))
.toEqual({ lead: 'roadtrip.bookend.checkIn', value: '15:00', warning: false, hint: null })
expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), { arrival: '12:27', dayOffset: 0 }, t, false))
.toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: true, hint: 'roadtrip.bookend.afterCheckOut' })
})
it('FE-BOOKEND-007: a day that sets out after the room is handed back says so', () => {
const checkOut = reading({ variant: 'checkOut', until: '10:00' })
expect(leavesAfterCheckOut(checkOut, { arrival: '12:27', dayOffset: 0 })).toBe(true)
expect(leavesAfterCheckOut(checkOut, { arrival: '10:01', dayOffset: 0 })).toBe(true)
expect(leavesAfterCheckOut(checkOut, { arrival: '10:00', dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(checkOut, { arrival: '08:30', dayOffset: 0 })).toBe(false)
// Past midnight it is later than any hour of the morning before.
expect(leavesAfterCheckOut(checkOut, { arrival: '01:00', dayOffset: 1 })).toBe(true)
})
it('FE-BOOKEND-008: nothing to say without a check-out hour or a clock to hold it against', () => {
expect(leavesAfterCheckOut(reading(), { arrival: '12:27', dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(reading({ variant: 'checkOut', until: '10:00' }), { arrival: null, dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(reading({ variant: 'checkOut', until: '10:00' }), undefined)).toBe(false)
})
it('FE-BOOKEND-004: a tap opens the booking behind the night for somebody who may edit bookings', () => {
expect(bookendBooking(reading(), true)).toBe(41)
// The editor is the only view a hotel booking has, so anybody else gets the place.
expect(bookendBooking(reading(), false)).toBeNull()
// A stay entered without a booking has none to open.
expect(bookendBooking(reading({ reservationId: null }), true)).toBeNull()
})
it('FE-BOOKEND-005: the hotel wears the bed the stay chips wear', () => {
expect(BOOKEND_ICON).toBe(BedDouble)
})
it('FE-BOOKEND-009: on the disc the hotel stop wears, in its signage colour with the bed in white', () => {
expect(BOOKEND_DISC.background).toBe(serviceColor('hotel'))
expect(BOOKEND_DISC.color).toBe('#fff')
})
})
describe('the hotel a booked night stands for', () => {
it('FE-BOOKEND-006: is read off the trip place as it is now, and the stay row stands in only where there is none', () => {
const moved = { id: 1, place_id: 10, place_name: 'Old name', place_lat: 47.2, place_lng: 11.4 }
const unknown = { id: 2, place_id: 20, place_name: 'Elsewhere', place_lat: 48.1, place_lng: 11.6 }
const placeless = { id: 3, place_id: null, place_name: null, place_lat: null, place_lng: null }
const places = [
{ id: 10, name: 'Hotel Alpenblick', lat: 47.3, lng: 11.5 },
{ id: 30, name: 'Unrelated', lat: 1, lng: 2 },
]
const read = staysAtTheirPlaces([moved, unknown, placeless], places)
expect(read[0]).toEqual({ id: 1, place_id: 10, place_name: 'Hotel Alpenblick', place_lat: 47.3, place_lng: 11.5 })
expect(read[1]).toBe(unknown)
expect(read[2]).toBe(placeless)
// A place that lost its pin takes the bookend with it, as the server's join does.
expect(staysAtTheirPlaces([moved], [{ id: 10, name: 'Hotel Alpenblick', lat: null, lng: null }])[0])
.toMatchObject({ place_lat: null, place_lng: null })
// Nothing to change hands the list back as it came, so nothing downstream recomputes.
const current = [{ ...moved, place_name: 'Hotel Alpenblick', place_lat: 47.3, place_lng: 11.5 }]
expect(staysAtTheirPlaces(current, places)).toBe(current)
expect(staysAtTheirPlaces(current, [])).toBe(current)
})
})
@@ -1,129 +0,0 @@
import type { CSSProperties } from 'react'
import { BedDouble, type LucideIcon } from 'lucide-react'
import { formatClockTime } from '../../utils/formatters'
import { parseClock, serviceColor, type ScheduleEntry } from './roadtripModel'
import type { TranslationFn } from '../../types'
import type { BookendReading } from './roadtripRowModel'
/**
* How a booked night at the edge of a day reads on the rail and in the phone chain, and
* what a tap on it opens.
*
* One module for both shells, the way `carrierRide` is for a ride: the two would otherwise
* carry the same sentence and the same decision each, and the duplication budget does not
* stretch to that. What the row IS comes from the row model (`bookendReading`); this only
* says it, and reads the hotel it stands for off the trip's places (`staysAtTheirPlaces`).
*/
/** The bed the day plan's stay chips wear, so the hotel looks like itself here too. */
export const BOOKEND_ICON: LucideIcon = BedDouble
/**
* The disc the bed sits on: the hotel stop's own, in the road-signage colour its map pin
* wears. On a plain grey disc the night at the day's edge read as a terminal, and the same
* hotel wore two faces on one card, one where the day checks in and another where it
* comes back to it.
*/
export const BOOKEND_DISC: CSSProperties = { background: serviceColor('hotel'), color: '#fff' } // theme-lint-disable: road-signage palette
/**
* The badge under the hotel's name: which edge of the stay the day stands at, and the hour
* that edge names, the latest the room is handed back or the earliest it is ready. Both are
* labels, never the time the drive leaves or arrives at (#2357). A plan that sets out after
* the check-out turns the badge itself to a warning rather than adding a line to the row.
*/
export interface BookendBadge {
lead: string
value: string | null
warning: boolean
hint: string | null
}
export function bookendBadge(
reading: BookendReading,
entry: Pick<ScheduleEntry, 'arrival' | 'dayOffset'> | undefined,
t: TranslationFn,
is12h: boolean,
): BookendBadge {
const clock = reading.until ?? reading.from
const value = clock ? formatClockTime(clock, is12h) : null
const warning = leavesAfterCheckOut(reading, entry)
return {
lead: t(`roadtrip.bookend.${reading.variant}`),
value,
warning,
hint: warning
? t('roadtrip.bookend.afterCheckOut')
: reading.until && value ? t('roadtrip.stay.until', { time: value }) : null,
}
}
/**
* Whether the plan leaves the stay later than the room is handed back. The check-out hour
* never moves the start of the day, so a day that sets out at 12:30 from a room given up
* at 10:00 is a plan worth a second look, and the row says so instead of standing the two
* clocks side by side without a word.
*/
export function leavesAfterCheckOut(
reading: BookendReading,
entry: Pick<ScheduleEntry, 'arrival' | 'dayOffset'> | undefined,
): boolean {
const until = parseClock(reading.until)
const leaves = parseClock(entry?.arrival)
if (until === null || leaves === null) return false
return leaves + (entry?.dayOffset ?? 0) * 24 * 60 > until
}
/** The fields of a trip place the hotel of a stay is read from. */
export interface HotelPlace {
id: number
name: string
lat?: number | null
lng?: number | null
}
/** The fields of a stay row that name its hotel and where it stands. */
export interface StayAtPlace {
place_id?: number | null
place_name?: string | null
place_lat?: number | null
place_lng?: number | null
}
/**
* The stays with their hotel where the trip's places have it now.
*
* A stay row carries its place's name and position as they were when the stays were last
* fetched, and the planner fetches them again on a stay's own edit only. A pin moved on the
* hotel reaches its visits at once (`mergeAssignmentPlace`) and the stay row not at all, so a
* bookend seated from the row stood at the old spot beside its own stop at the new one: the
* day drove from the one to the other, and the server, which joins the place afresh, planned
* another drive. The place wins; the row stands in only for a place the list does not hold.
* Rows that need nothing come back as they were, and so does the list when none does.
*/
export function staysAtTheirPlaces<S extends StayAtPlace>(stays: readonly S[], places: readonly HotelPlace[]): readonly S[] {
if (!stays.length || !places.length) return stays
const byId = new Map(places.map(place => [place.id, place]))
let changed = false
const out = stays.map(stay => {
const place = stay.place_id == null ? undefined : byId.get(stay.place_id)
if (!place) return stay
const lat = place.lat ?? null
const lng = place.lng ?? null
if (stay.place_lat === lat && stay.place_lng === lng && stay.place_name === place.name) return stay
changed = true
return { ...stay, place_lat: lat, place_lng: lng, place_name: place.name }
})
return changed ? out : stays
}
/**
* The booking a tap on the row opens, or null when it opens the stay's place instead.
*
* The booking behind the night when there is one and this reader may open it: a hotel
* booking has only its editor, the split `bookingOpens` makes for a chip. Otherwise the
* place, which anybody may look at.
*/
export function bookendBooking(reading: BookendReading, canEditBookings: boolean): number | null {
return canEditBookings ? reading.reservationId : null
}
@@ -1,39 +0,0 @@
import { useEffect, useRef, useState } from 'react'
/**
* The dropdown the corridor panel asks its question with: what to look for, and along which
* day. One shape for both, so the two sit side by side as one line of the same control.
*/
/** The closed control: one line, on the card's own ground, with the card's edge. */
export const DROPDOWN_TRIGGER =
'flex h-[32px] items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint'
/** The list it opens, under it and at least as wide. */
export const DROPDOWN_LIST =
'absolute top-full z-[var(--z-toast)] mt-1 w-max min-w-full overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur'
/** One option in that list. */
export const DROPDOWN_ROW = 'flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover'
/** Open or not, closed again by a press outside it or by Escape. */
export function useDropdown<T extends HTMLElement>() {
const [open, setOpen] = useState(false)
const ref = useRef<T>(null)
useEffect(() => {
if (!open) return
const onPointer = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open])
return { open, setOpen, ref }
}
@@ -175,73 +175,3 @@ describe('reanchorAfterReorder', () => {
expect(plan.vias).toEqual([])
})
})
/**
* A via behind a day's last stop shapes the drive into the next day, on a trip with
* connected days or a night drive: the map files a point dropped on that drive there, and
* a way chosen for it is written there. It is no leg of its own day, so the arithmetic
* above has to be told what it is (`carriedSeam`) or it takes it for one leading nowhere.
*/
describe('the drive into the next day (#2461)', () => {
it('FE-REANCHOR-023: a wholesale reorder that leaves the last stop last keeps the drive behind it', () => {
// Stops 10, 20, 30, 40; the via behind 40 is the join. Swapping the first two used to
// delete it, because a last stop has no leg.
const plan = reanchorByStopOrder([via(1, 0), via(9, 3)], [10, 20, 30, 40], [20, 10, 30, 40])
expect(anchors(plan.vias)).toEqual({ 1: 1 })
expect(plan.remove).toEqual([])
expect(isEmptyReanchoring(reanchorByStopOrder([via(9, 3)], [10, 20, 30, 40], [10, 20, 30, 40]))).toBe(true)
})
it('FE-REANCHOR-024: taking out the last stop takes the drive out of it along, instead of leaving it on no stop', () => {
const plan = reanchorAfterRemove([via(2, 2), via(9, 3)], 3, 4)
expect(plan.remove).toEqual([2, 9])
expect(plan.vias).toEqual([])
})
it('FE-REANCHOR-025: taking out any other stop leaves it behind the same last stop, one number down', () => {
expect(anchors(reanchorAfterRemove([via(9, 3)], 1, 4).vias)).toEqual({ 9: 2 })
expect(anchors(reanchorAfterRemove([via(9, 3)], 0, 4).vias)).toEqual({ 9: 2 })
// A day left with one stop has no leg, but still the drive out of it.
const single = reanchorAfterRemove([via(1, 0), via(9, 1)], 0, 2)
expect(anchors(single.vias)).toEqual({ 9: 0 })
expect(single.remove).toEqual([1])
})
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. 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)
})
it('FE-REANCHOR-027: a stop appended behind the last one keeps the point on the road to it', () => {
expect(isEmptyReanchoring(reanchorAfterInsert([via(9, 3)], 4, () => true))).toBe(true)
})
it('FE-REANCHOR-028: two stops swapped keep their leg, and the drive out of the one that is first now goes', () => {
for (const [from, to] of [[0, 1], [1, 0]] as const) {
const plan = reanchorAfterReorder([via(1, 0), via(9, 1)], from, to, 2)
expect(plan.remove, `${from}->${to}`).toEqual([9])
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] })
})
})
@@ -1,13 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
arrivingReroutable,
bookendReading,
destinationCount,
firstStopOfPlace,
legReroutable,
movableWithin,
pickWarning,
resumeFoldsIntoBookend,
roadtripRows,
stageClocks,
stageEnd,
@@ -17,9 +13,9 @@ import {
type StopRow,
} from './roadtripRowModel'
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { BookendPhase, NightBookend, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-067
// FE-RTROW-001 to FE-RTROW-044
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -231,16 +227,14 @@ describe('roadtripRows stop detail', () => {
})
it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => {
// Where each finding really comes from: being late from the schedule, the drive's
// limits from `driveWarnings`.
const rows = stopRows(
roadtripRows(
day([stop('A'), stop('B')], {
driveWarnings: [
{ index: 0, code: 'overnight' },
{ index: 1, code: 'leg', overMinutes: 20 },
{ index: 1, code: 'late', minutes: 15 },
],
schedule: { entries: [entry(null), entry(null)], warnings: [{ index: 1, code: 'late', minutes: 15 }] },
}),
),
)
@@ -248,32 +242,6 @@ describe('roadtripRows stop detail', () => {
expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 })
})
it('FE-RTROW-067: being late, or leaving after the time set, reaches the row from the schedule, a terminal\'s included', () => {
const terminal = stop('HAM', {
carrier: { reservationId: 78, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' },
})
const rows = stopRows(
roadtripRows(
day([stop('A'), stop('B'), terminal], {
schedule: {
entries: [entry('09:00'), entry('10:00'), entry('14:15')],
warnings: [
{ index: 1, code: 'missedLeave', minutes: 20 },
{ index: 2, code: 'late', minutes: 579 },
// Only the lateness is read from the schedule, not the day it changes on.
{ index: 0, code: 'overnight' },
],
},
}),
),
)
expect(rows.map(r => r.warning)).toEqual([
null,
{ index: 1, code: 'missedLeave', minutes: 20 },
{ index: 2, code: 'late', minutes: 579 },
])
})
it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => {
const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')])))
expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240])
@@ -636,229 +604,4 @@ describe('roadtripRows with a ride (#2428)', () => {
expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
})
/** Bremen at 09:00, the airport at 12:20 for a 13:20 flight, landing at 14:30, nothing after. */
const endsOnRide = () =>
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [seg(0), ride],
schedule: { entries: [entry('09:00'), entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
/** The flight first, the hotel after it. */
const opensOnRide = () =>
day([terminal('Munich Airport', 'departure', '06:00'), terminal('Hamburg Airport', 'arrival', '07:10'), stop('Hotel')], {
legs: [ride, seg(1)],
schedule: { entries: [entry('05:00', { departure: '06:00' }), entry('07:10'), entry('09:00')], warnings: [] },
})
it('FE-RTROW-052: a day that ends on a ride arrives at the arrival terminal, one that opens on a ride starts at the departure', () => {
// The head card printed the clock of the stop before the flight as where the day ends,
// while the ride block right below it printed the landing.
expect(stageClocks(roadtripRows(endsOnRide()))).toEqual({ start: '09:00', arrive: '14:30' })
expect(stageClocks(roadtripRows(opensOnRide()))).toEqual({ start: '05:00', arrive: '09:00' })
})
it('FE-RTROW-053: a day that is nothing but a ride still has both clocks', () => {
const rideOnly = day([terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [ride],
schedule: { entries: [entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
expect(stageClocks(roadtripRows(rideOnly))).toEqual({ start: '12:20', arrive: '14:30' })
})
it('FE-RTROW-054: up next names the departure terminal before its check-in, and the arrival after take-off', () => {
// A lone terminal (a ride landing tomorrow) was already a candidate; the terminals
// inside a same-day ride were not, so the countdown skipped the airport.
expect(upNextStop(endsOnRide(), 10 * 60, true)?.row.stop.name).toBe('Munich Airport')
expect(upNextStop(endsOnRide(), 10 * 60, true)?.minutesUntil).toBe(140)
expect(upNextStop(endsOnRide(), 13 * 60 + 30, true)?.row.stop.name).toBe('Hamburg Airport')
expect(upNextStop(endsOnRide(), 15 * 60 + 1, true)?.minutesUntil).toBe(-31)
})
it('FE-RTROW-055: a stage that ends on a ride ends at its arrival terminal', () => {
const end = stageEnd(roadtripRows(endsOnRide()))
expect(end?.stop.name).toBe('Hamburg Airport')
expect(end?.time).toBe('14:30')
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
})
})
describe('the drive in from the day before (#2461)', () => {
/** Where yesterday ended: stored on day 6, its third stop, and drawn on no stop of this card. */
const yesterday = stop('Lüneburg', { ownerDayId: 6, ownerIndex: 2, legMode: null })
const drive: RouteSegment = { ...seg(4), distance: 120_000, duration: 5_400 }
/** A card joined to the one before it, with the drive and the line it is drawn on. */
const joined = (over: Partial<RoadtripDay> = {}) => day([stop('Berlin', { incomingLegMode: 'driving' }), stop('Potsdam')], {
arrivingLeg: drive,
arrivingFrom: yesterday,
arrivingLine: [[53.2, 10.4], [52.5, 13.4]],
...over,
})
it('FE-RTROW-056: heads the card with a row of its own that names where it leaves, and counts no stop for it', () => {
const rows = roadtripRows(joined())
expect(rows.map(r => r.kind)).toEqual(['arriving', 'stop', 'leg', 'stop'])
expect(rows[0]).toMatchObject({ kind: 'arriving', seg: drive, from: { name: 'Lüneburg' }, mode: 'driving' })
// Still two stops, numbered from one: the stop it leaves is not on this card.
expect(stopRows(rows).map(r => r.number)).toEqual([1, 2])
expect(destinationCount(joined())).toBe(2)
expect(stageClocks(rows)).toEqual(stageClocks(roadtripRows(joined({ arrivingLeg: undefined }))))
expect(stageEnd(rows)?.stop.name).toBe('Potsdam')
})
it('FE-RTROW-057: no row for a hop, a ride, or a drive whose start the card was not told', () => {
const hop: RouteSegment = { ...seg(0), distance: 80, duration: 60 }
const ride: RouteSegment = { ...seg(0), distance: 0, duration: 9 * 3600, mode: 'flight' }
for (const over of [{ arrivingLeg: hop }, { arrivingLeg: ride }, { arrivingFrom: undefined }, { arrivingLeg: undefined }]) {
expect(roadtripRows(joined(over)).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
}
})
it('FE-RTROW-058: other ways are offered on it exactly where a leg would be offered them', () => {
expect(arrivingReroutable(joined())).toBe(true)
// Nothing to offer against: no drive, no line, no stop it leaves or reaches.
expect(arrivingReroutable(joined({ arrivingLeg: undefined }))).toBe(false)
expect(arrivingReroutable(joined({ arrivingLine: undefined }))).toBe(false)
expect(arrivingReroutable(joined({ arrivingFrom: undefined }))).toBe(false)
expect(arrivingReroutable(joined({ stops: [] }))).toBe(false)
// A ride has no other way, and a hop is nothing to weigh.
expect(arrivingReroutable(joined({ arrivingLeg: { ...drive, mode: 'train' } }))).toBe(false)
expect(arrivingReroutable(joined({ arrivingLeg: { ...drive, distance: 80, duration: 60 } }))).toBe(false)
// A terminal leaves nothing a via can be filed behind.
const terminal = stop('Hamburg Airport', { carrier: { reservationId: 7, type: 'flight', role: 'arrival', title: 'LH 2020', code: null, at: '07:00' } })
expect(arrivingReroutable(joined({ arrivingFrom: terminal }))).toBe(false)
// An automatic night at either end is a marker on the road, not a stop anybody chose.
expect(arrivingReroutable(joined({ arrivingFrom: night() }))).toBe(false)
expect(arrivingReroutable(joined({ stops: [night('start'), stop('Potsdam')] }))).toBe(false)
})
})
describe('a booked night at the edge of the day', () => {
const reading = (phase: BookendPhase, over: Partial<NightBookend> = {}): NightBookend => ({
phase,
accommodationId: 5,
reservationId: 41,
checkingOut: false,
checkingIn: false,
checkOut: null,
...over,
})
const hotel = (phase: BookendPhase, over: Partial<NightBookend> = {}, at: Partial<RoadtripStop> = {}) =>
stop('Hotel Alpenblick', {
assignmentId: phase === 'morning' ? -6_000_000_014 : -6_000_000_015,
placeId: 900,
lat: 45,
lng: 7,
stopType: 'hotel',
dwellMinutes: 0,
bookend: reading(phase, over),
...at,
})
/** From the hotel, two places, back to the hotel, each with a clock. */
const loop = (over: Partial<RoadtripDay> = {}) => {
const stops = [
hotel('morning'),
stop('Lookout', { placeId: 1 }),
stop('Falls', { placeId: 2, ownerIndex: 1 }),
hotel('evening', {}, { ownerIndex: 2 }),
]
return day(stops, {
schedule: { entries: [entry('08:40'), entry('09:30'), entry('11:00'), entry('12:10')], warnings: [] },
...over,
})
}
it('FE-RTROW-059: reads as a check-out, a morning, an evening back or a check-in', () => {
const out = day([hotel('morning', { checkingOut: true, checkOut: '10:00' }), stop('Zoo')])
expect(bookendReading(out, 0)).toEqual({
phase: 'morning',
variant: 'checkOut',
name: 'Hotel Alpenblick',
until: '10:00',
from: null,
reservationId: 41,
accommodationId: 5,
placeId: 900,
})
expect(bookendReading(loop(), 0)).toMatchObject({ variant: 'from', until: null })
expect(bookendReading(loop(), 3)).toMatchObject({ variant: 'back', phase: 'evening' })
// The check-in day: back when the stay's own stop heads the card, a check-in without it.
const stayed = day([stop('Hotel Alpenblick', { lat: 45, lng: 7, night: true }), stop('Lookout'), hotel('evening', { checkingIn: true })])
expect(bookendReading(stayed, 2)?.variant).toBe('back')
const transfer = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true }, { name: 'Wallinga', lat: 46 })])
expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga', from: null })
// The hour the room is ready rides along on a check-in, as a label and nowhere else.
const ready = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true, checkIn: '15:00' }, { name: 'Wallinga', lat: 46 })])
expect(bookendReading(ready, 1)).toMatchObject({ variant: 'checkIn', from: '15:00' })
expect(bookendReading(ready, 0)?.from).toBeNull()
// Nothing to read on an ordinary stop, or past the end.
expect(bookendReading(loop(), 1)).toBeNull()
expect(bookendReading(loop(), 9)).toBeNull()
})
it('FE-RTROW-060: a row of its own, unnumbered, that counts as no destination', () => {
const rows = roadtripRows(loop())
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop', 'leg', 'stop'])
const stops = stopRows(rows)
expect(stops.map(r => r.number)).toEqual([null, 1, 2, null])
expect(stops.map(r => r.bookend?.variant ?? null)).toEqual(['from', null, null, 'back'])
expect(stops[0]!.service).toBe(true)
expect(destinationCount(loop())).toBe(2)
})
it('FE-RTROW-061: offers no other ways from or to the hotel, on a leg or on the drive in', () => {
expect([0, 1, 2].map(i => legReroutable(loop(), i))).toEqual([false, true, false])
const joined = (from: RoadtripStop, first: RoadtripStop) => day([first, stop('Falls')], {
arrivingLeg: { ...seg(3), distance: 120_000, duration: 5_400 },
arrivingFrom: from,
arrivingLine: [[45, 7], [48, 11]],
})
expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), stop('Lookout')))).toBe(true)
expect(arrivingReroutable(joined(hotel('evening'), stop('Lookout')))).toBe(false)
expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), hotel('morning')))).toBe(false)
})
it('FE-RTROW-062: the morning marker at the hotel the day sets out from is folded into its row', () => {
const resume = night('start')
const at = { ...resume, lat: 45, lng: 7 }
const folded = day([at, hotel('morning'), stop('Lookout')], {
schedule: { entries: [entry('08:00'), entry('08:00'), entry('09:00')], warnings: [] },
})
expect(resumeFoldsIntoBookend(folded, 0)).toBe(true)
expect(roadtripRows(folded).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
expect(stageClocks(roadtripRows(folded)).start).toBe('08:00')
// A marker somewhere else keeps its own row: the drive from there to the hotel is real.
const apart = day([resume, hotel('morning'), stop('Lookout')])
expect(resumeFoldsIntoBookend(apart, 0)).toBe(false)
expect(roadtripRows(apart)[0]).toMatchObject({ kind: 'auto', phase: 'resume' })
expect(resumeFoldsIntoBookend(folded, 1)).toBe(false)
})
it('FE-RTROW-063: the stage starts when the hotel is left and arrives when it is reached again', () => {
const rows = roadtripRows(loop())
expect(stageClocks(rows)).toEqual({ start: '08:40', arrive: '12:10' })
expect(stageEnd(rows)?.bookend?.variant).toBe('back')
})
it('FE-RTROW-064: a pin at the hotel opens the stay’s own stop, never a bookend', () => {
const own = stop('Hotel Alpenblick', { placeId: 900, assignmentId: 77, ownerDayId: 3 })
expect(firstStopOfPlace([loop(), day([own])], 900)).toBe(own)
expect(firstStopOfPlace([loop()], 900)).toBeNull()
})
it('FE-RTROW-065: a stop moves past anything but the hotel at the edge of its day', () => {
expect(movableWithin(loop(), 1)).toEqual({ up: false, down: true })
expect(movableWithin(loop(), 2)).toEqual({ up: true, down: false })
const plain = day([stop('A'), stop('B'), stop('C')])
expect([0, 1, 2].map(i => movableWithin(plain, i))).toEqual([
{ up: false, down: true },
{ up: true, down: true },
{ up: true, down: false },
])
})
it('FE-RTROW-066: up next is a place, not the hotel the day comes back to', () => {
// 11:30 is past both places and before the hotel: the last place is still next.
expect(upNextStop(loop(), 11 * 60 + 30, true)?.row.stop.name).toBe('Falls')
expect(upNextStop(loop(), 8 * 60 + 45, true)?.row.stop.name).toBe('Lookout')
})
})
@@ -1,5 +1,5 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import { isCarrierMode, isStoredStop, type BookendPhase, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { CarrierTerminal, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import { readStay } from './stayReading'
/**
@@ -21,12 +21,6 @@ export type RoadtripRow =
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
| StopRow
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
/**
* The drive in from where the day before ended, above the card's first stop, on a trip
* with connected days. `from` is that last stop of the day before, which the card does
* not draw, so the row names it.
*/
| { kind: 'arriving'; seg: RouteSegment; from: RoadtripStop; mode: string | null }
/**
* A ride that leaves and lands on the day (#2428), as one row: the booking with both
* its terminals inside it, in place of two stop rows and the leg between them. The
@@ -54,76 +48,6 @@ export interface StopRow {
/** How long the stop is stood at, which for one left at a set time is what that time leaves. */
dwellMinutes: number | null
offRoadMeters: number | null
/** What the row says about the booked night it stands for, when it is one; see `bookendReading`. */
bookend: BookendReading | null
}
/**
* A booked night at the edge of a day, as a row reads it (`seatNightBookends`).
*
* Four ways of saying it. The morning of the day the stay is handed back is a check-out,
* with the latest hour it may be; any other morning sets out from the stay. The evening
* of the day the stay begins is a check-in, unless the stay's own stop is already on the
* card before it, in which case the day comes back to it like any other evening.
*/
export interface BookendReading {
phase: BookendPhase
variant: 'checkOut' | 'from' | 'back' | 'checkIn'
name: string
/** The latest the room is handed back, on the check-out morning. A label, never a departure. */
until: string | null
/** From when the room is ready, on a check-in evening. A label, never an arrival. */
from: string | null
/** The booking a tap opens, or null for a stay entered without one. */
reservationId: number | null
accommodationId: number
placeId: number
}
export function bookendReading(day: Pick<RoadtripDay, 'stops'>, index: number): BookendReading | null {
const stop = day.stops[index]
const bookend = stop?.bookend
if (!stop || !bookend) return null
const morning = bookend.phase === 'morning'
const stayStopBefore = day.stops
.slice(0, index)
.some(s => isStoredStop(s) && s.lat === stop.lat && s.lng === stop.lng)
const evening = bookend.checkingIn && !stayStopBefore ? 'checkIn' : 'back'
return {
phase: bookend.phase,
variant: morning ? (bookend.checkingOut ? 'checkOut' : 'from') : evening,
name: stop.name,
until: morning && bookend.checkingOut ? bookend.checkOut : null,
from: evening === 'checkIn' ? (bookend.checkIn ?? null) : null,
reservationId: bookend.reservationId,
accommodationId: bookend.accommodationId,
placeId: stop.placeId,
}
}
/**
* Whether the automatic morning marker at `index` is the same point as the hotel the day
* sets out from right after it. The hotel's row carries that morning, clock and all, and a
* "continue journey" line above it would say the same thing twice.
*/
export function resumeFoldsIntoBookend(day: Pick<RoadtripDay, 'stops'>, index: number): boolean {
const stop = day.stops[index]
const next = day.stops[index + 1]
return stop?.automaticNight?.phase === 'start'
&& next?.bookend?.phase === 'morning'
&& stop.lat === next.lat
&& stop.lng === next.lng
}
/**
* Where the stop at `index` may be moved by one: not past a booked night at the day's
* edge, which is no stored stop and holds its place. Past anything else, as before.
*/
export function movableWithin(day: Pick<RoadtripDay, 'stops'>, index: number): { up: boolean; down: boolean } {
return {
up: index > 0 && !day.stops[index - 1]?.bookend,
down: index < day.stops.length - 1 && !day.stops[index + 1]?.bookend,
}
}
/**
@@ -156,18 +80,17 @@ export function stageOf(days: readonly RoadtripDay[], dayId: number | null): Roa
return days.find(d => d.dayId === dayId) ?? null
}
function stopRow(day: RoadtripDay, index: number, number: number | null): StopRow {
function stopRow(
stop: RoadtripStop,
index: number,
number: number | null,
schedule: RoadtripDay['schedule'],
driveWarnings: readonly ScheduleWarning[],
): StopRow {
// Only ever called for a real stop: an automatic night is the shell's own marker
// for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead.
const stop = day.stops[index]
const entry = day.schedule.entries[index]
// Being late comes from the schedule and the drive's own findings from the limits: two
// lists, and a row reading only the second never showed a stop reached late, or a ride
// the drive gets to after it has left.
const mine = [
...day.driveWarnings,
...day.schedule.warnings.filter(w => w.code === 'late' || w.code === 'missedLeave'),
].filter(w => w.index === index)
const entry = schedule.entries[index]
const mine = driveWarnings.filter(w => w.index === index)
return {
kind: 'stop',
stop,
@@ -180,7 +103,6 @@ function stopRow(day: RoadtripDay, index: number, number: number | null): StopRo
warning: pickWarning(mine),
dwellMinutes: readStay(stop, entry).minutes,
offRoadMeters: stop.offRoadMeters ?? null,
bookend: bookendReading(day, index),
}
}
@@ -234,20 +156,6 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
const rows: RoadtripRow[] = []
const spills = day.spills ?? []
// The drive in from yesterday, where the desktop rail draws its band: above the first
// stop, because that is where it happens. A hop is left out like any other, and so is
// a ride: the terminal row it lands on already carries the booking, and a pill would
// read "No route" under a flight that has no road.
const arriving = day.arrivingLeg
if (arriving && day.arrivingFrom && !isHop(arriving) && !isCarrierMode(arriving.mode)) {
rows.push({
kind: 'arriving',
seg: arriving,
from: day.arrivingFrom,
mode: day.stops[0]?.incomingLegMode ?? day.arrivingFrom.legMode ?? null,
})
}
let number = 0
day.stops.forEach((stop, i) => {
const spill = spills.find(s => s.at === i)
@@ -268,8 +176,6 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return
const automatic = !!stop.automaticNight
// The morning marker at the hotel the day sets out from is that hotel's row.
if (resumeFoldsIntoBookend(day, i)) return
if (automatic) {
rows.push({
kind: 'auto',
@@ -282,8 +188,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
index: i,
carrier: stop.carrier,
seg: day.legs[i],
departure: stopRow(day, i, null),
arrival: stopRow(day, i + 1, null),
departure: stopRow(stop, i, null, day.schedule, day.driveWarnings),
arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings),
})
// The road out of the arrival belongs to this row too; the pass below only
// looks at the index it is on.
@@ -294,7 +200,7 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
// place the trip is for: it carries no number, the way a service stop carries none.
const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
if (!unnumbered) number += 1
rows.push(stopRow(day, i, unnumbered ? null : number))
rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings))
}
pushLeg(i)
@@ -342,36 +248,6 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
// index of the stop after it. The road INTO a departure terminal leaves a stored
// stop and can be offered other ways like any other.
&& !day.stops[index].carrier
// The drive from the hotel a day sets out from, or to the one it ends at, takes no
// via: the morning's has no index to file one at, and the evening's index is the
// road into tomorrow's, which a choice here would bend instead.
&& !day.stops[index].bookend
&& !day.stops[index + 1].bookend
}
/**
* Whether the drive arriving at the head of a connected card can be offered other ways.
*
* The same rule as `legReroutable`, read for the one drive a card holds without a stop of
* its own at its start: it leaves the last stop of the day before (`arrivingFrom`), and a
* choice is filed behind that stop, on that day, where the map already files a point
* dropped on this drive. It needs its road and the line it is drawn on, since that is
* what the picker offers as the current way. A ride has no other way, a hop is nothing to
* weigh, a terminal leaves nothing a via can be filed behind, and an automatic night at
* either end is a marker on the road rather than a stop anybody chose.
*/
export function arrivingReroutable(day: RoadtripDay): boolean {
const seg = day.arrivingLeg
const from = day.arrivingFrom
const to = day.stops[0]
return !!seg && !!from && !!to && !!day.arrivingLine
&& !isCarrierMode(seg.mode)
&& !isHop(seg)
&& !from.carrier
&& !from.automaticNight
&& !to.automaticNight
&& !from.bookend
&& !to.bookend
}
/** Stops that carry a number, for a count that agrees with the numbering above. */
@@ -379,15 +255,6 @@ export function destinationCount(day: RoadtripDay): number {
return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
}
/**
* The rows with each ride opened up into its two terminals, in place. A ride row holds
* them instead of two stop rows of their own, so a reader walking the rows for stops
* has to look inside it, or a day that ends on a flight ends at the stop before it.
*/
function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
return rows.flatMap(row => (row.kind === 'ride' ? [row.departure, row.arrival] : [row]))
}
/**
* The two clocks a stage is headed with: when it starts, and when you reach its last place.
*
@@ -417,7 +284,7 @@ function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } {
let start: string | null = null
let arrive: string | null = null
for (const row of unfoldRides(rows)) {
for (const row of rows) {
if (row.kind === 'stop' && row.time) {
start ??= row.time
arrive = row.time
@@ -429,8 +296,7 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
}
/**
* The stop a stage ends on: the last one the chain draws, a service stop or the arrival
* terminal of a ride included.
* The stop a stage ends on: the last one the chain draws, a service stop included.
*
* The map half names it in its bar and opens it on a tap, so the name, the clock beside it
* and the sheet the tap brings up all come off this one row and cannot name three different
@@ -442,9 +308,8 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
* There is nothing for a tap to open then, and the bar says so by not being a button.
*/
export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
const unfolded = unfoldRides(rows)
for (let i = unfolded.length - 1; i >= 0; i--) {
const row = unfolded[i]
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i]
if (row.kind === 'stop') return row
}
return null
@@ -461,13 +326,12 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
* traveller reading the drive from its start reaches first.
*
* An automatic night can sit on a place's position, but nobody chose to stop there, so it
* never answers. Neither does a booked night at a day's edge: it is the stay's place, and
* no stop of the day, so the inspector answers for the hotel instead. Null when none of the days stops at the place, which is a pin the road
* never answers. Null when none of the days stops at the place, which is a pin the road
* trip cannot explain and the caller hands to the plan's own place inspector instead.
*/
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null {
for (const day of days) {
const stop = day.stops.find(s => isStoredStop(s) && s.placeId === placeId)
const stop = day.stops.find(s => !s.automaticNight && !s.carrier && s.placeId === placeId)
if (stop) return stop
}
return null
@@ -491,7 +355,7 @@ export function upNextStop(
isToday: boolean,
): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return null
const timed = unfoldRides(roadtripRows(day))
const timed = roadtripRows(day)
.filter((r): r is StopRow => r.kind === 'stop' && !r.service)
.map(row => ({ row, at: clockMinutes(row.time) }))
.filter((x): x is { row: StopRow; at: number } => x.at != null)
@@ -3,7 +3,7 @@ import { dayColor } from './dayColors'
import { stageMapData, stagePlaceIds, stagePoints } from './stageMap'
import type { AccessSpur, RoadtripDay, RoadtripRoutes, RoadtripStop } from '@trek/shared/roadtrip'
// FE-RTSTAGE-001 to FE-RTSTAGE-021
// FE-RTSTAGE-001 to FE-RTSTAGE-015
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -248,22 +248,3 @@ describe('stagePoints', () => {
])
})
})
describe('a stage that starts or ends at a booked night', () => {
it('FE-RTSTAGE-021: the hotel is on the stage, pin, fit and walk from the road, when only its bookend stands there', () => {
const hotel = stop('Hotel Alpenblick', {
assignmentId: -6_000_000_014,
placeId: 900,
lat: 47.2,
lng: 11.4,
stopType: 'hotel',
bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
})
const stage = day([hotel, stop('Kassel', { placeId: 11, lat: 51.31, lng: 9.49 })])
const walk = spur(keyFor(hotel))
const data = stageMapData(routes({ lines: [[[47.2, 11.4], [51.31, 9.49]]], lineDays: [2], lineJoins: [false], accessLines: [walk] }), stage, false)
expect([...data.placeIds]).toEqual([900, 11])
expect(data.focusPoints[0]).toEqual([47.2, 11.4])
expect(data.accessLines).toEqual([walk])
})
})
@@ -55,23 +55,6 @@ describe('dayBookings (#2428)', () => {
expect(dayBookings(day, [])).toEqual({ atStop: new Map(), loose: [] })
})
it('FE-STOPBOOKINGS-005: a booking spanning several days is on the days between as well, unless its type hides there', () => {
// Day ids out of order on purpose: the span is read by the days' position, not by id.
const order = (dayId: number) => ({ 5: 0, 1: 1, 3: 2 } as Record<number, number>)[dayId] ?? null
const tour = booking({ id: 1, title: 'Three-day tour', type: 'tour', day_id: 5, end_day_id: 3 })
const parked = booking({ id: 2, title: 'Parked', type: 'parking', day_id: 5, end_day_id: 3 })
const dinner = booking({ id: 3, title: 'Dinner', day_id: 5, end_day_id: 3, place_id: 110 })
const { atStop, loose } = dayBookings(day, [tour, parked, dinner], order)
expect(loose.map(r => r.title)).toEqual(['Three-day tour'])
// On the day between, a booking for a place the day drives to still hangs under it.
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Dinner'])
// Its first and last day list the parked car as before.
expect(dayBookings({ ...day, dayId: 3 }, [parked], order).loose).toHaveLength(1)
// Without a day order, or for a day the order does not know, only the two ends count.
expect(dayBookings(day, [tour])).toEqual({ atStop: new Map(), loose: [] })
expect(dayBookings({ ...day, dayId: 9 }, [tour], order)).toEqual({ atStop: new Map(), loose: [] })
})
it('FE-STOPBOOKINGS-004: the chip wears the booking panel\'s icon and the clock the booking starts at', () => {
expect(bookingIcon('restaurant')).toBe(Utensils)
expect(bookingIcon('car')).toBe(Car)
@@ -80,24 +63,3 @@ describe('dayBookings (#2428)', () => {
expect(bookingClock({ reservation_time: null })).toBeNull()
})
})
describe('dayBookings and a booked night at the edge of the day', () => {
it('FE-STOPBOOKINGS-006: a table at the hotel hangs under the hotel stop, never under the bookend in front of it', () => {
const bookend = (phase: 'morning' | 'evening', assignmentId: number) => ({
assignmentId,
placeId: 900,
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
})
const withHotel = {
dayId: 1,
stops: [bookend('morning', -6_000_000_002), { assignmentId: 11, placeId: 110 }, { assignmentId: 13, placeId: 900 }],
}
const dinner = booking({ id: 9, title: 'Dinner at the hotel', place_id: 900 })
expect([...dayBookings(withHotel, [dinner]).atStop.keys()]).toEqual([2])
// With the hotel only there as the evening's bookend, the table is the day's, for no stop.
const backOnly = { dayId: 1, stops: [{ assignmentId: 11, placeId: 110 }, bookend('evening', -6_000_000_003)] }
const { atStop, loose } = dayBookings(backOnly, [dinner])
expect(atStop.size).toBe(0)
expect(loose.map(r => r.title)).toEqual(['Dinner at the hotel'])
})
})
+6 -27
View File
@@ -1,7 +1,6 @@
import { Ticket, type LucideIcon } from 'lucide-react'
import { isCarrierType, isRentalType, isStoredStop } from '@trek/shared/roadtrip'
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
import { hidesOnMiddleDay } from '../../utils/dayMerge'
import { splitReservationDateTime } from '../../utils/formatters'
import type { Reservation } from '../../types'
import type { RoadtripStop } from '@trek/shared/roadtrip'
@@ -43,27 +42,9 @@ function byClock(a: Reservation, b: Reservation): number {
return a.id - b.id
}
/**
* Whether a booking is on the day: it starts or ends there, or the day lies between the
* two, the way the day plan lists a three-day tour on its middle day as well. A type
* that has nothing to say on its middle days (a parked car) is left off them here too.
* Without a day order only the first and the last day of a span can be told.
*/
function spansDay(r: Reservation, dayId: number, dayOrder?: (dayId: number) => number | null | undefined): boolean {
if (r.day_id === dayId || (r.end_day_id != null && r.end_day_id === dayId)) return true
if (!dayOrder || r.day_id == null || r.end_day_id == null) return false
const here = dayOrder(dayId)
const first = dayOrder(r.day_id)
const last = dayOrder(r.end_day_id)
if (here == null || first == null || last == null) return false
return here > Math.min(first, last) && here < Math.max(first, last) && !hidesOnMiddleDay(r, dayId)
}
export function dayBookings(
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight' | 'bookend'>[] },
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
reservations: readonly Reservation[],
/** A day's position in the trip by its id, so a booking spanning days is on the days between too. */
dayOrder?: (dayId: number) => number | null | undefined,
): DayBookings {
if (!reservations.length) return EMPTY
const atStop = new Map<number, Reservation[]>()
@@ -71,13 +52,11 @@ export function dayBookings(
for (const r of reservations) {
if (drawnElsewhere(r.type)) continue
const stops = day.stops
// Only a real stop can hold a booking: a terminal has a negative place, an automatic
// night has no assignment anybody pinned to, and a booked night at the day's edge is
// the stay's place on a row that carries no chips. A table at the hotel belongs to the
// hotel's own stop, or under the day.
const real = (i: number): boolean => isStoredStop(stops[i]!)
// Only a real stop can hold a booking: a terminal has a negative place and an
// automatic night has no assignment anybody pinned to.
const real = (i: number): boolean => !stops[i]!.carrier && !stops[i]!.automaticNight
let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
const onDay = spansDay(r, day.dayId, dayOrder)
const onDay = r.day_id === day.dayId || (r.end_day_id != null && r.end_day_id === day.dayId)
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
if (index >= 0) {
const list = atStop.get(index) ?? []
@@ -283,20 +283,4 @@ describe('useCorridorPois', () => {
expect(plain.current.results.map(r => r.osm_id).sort()).toEqual(['on-the-road', 'under-the-flight'])
expect(plain.current.progress.total).toBeGreaterThan(result.current.progress.total)
})
it('FE-ROADTRIP-CORRIDOR-019: the gaps are a dependency like any other, so a memoised array keeps `search` and a fresh one remakes it', () => {
// The caller memoises the array per day; the hook does not key it on its contents
// behind the lint rule's back.
const gaps = [{ from: LONG[3], to: LONG[8] }]
const categories = ['fuel']
const { result, rerender } = renderHook(
({ options }: { options: { gaps: typeof gaps } }) => useCorridorPois(LONG, categories, 10, options),
{ initialProps: { options: { gaps } } },
)
const search = result.current.search
rerender({ options: { gaps } })
expect(result.current.search).toBe(search)
rerender({ options: { gaps: [...gaps] } })
expect(result.current.search).not.toBe(search)
})
})
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
import { useTranslation } from '../../i18n'
import { corridorTiles, drivenPieces, inRiddenRange, projectOntoRoute, riddenRanges, simplifyLine, type Bbox, type LatLng } from './corridor'
import { corridorTiles, projectOntoRoute, simplifyLine, sliceAtMeters, type Bbox, type LatLng } from './corridor'
import { DESKTOP_CORRIDOR_BUDGET, type CorridorBudget } from './corridorSearchModel'
import type { Poi } from '../Map/poiCategories'
@@ -93,14 +93,14 @@ export function useCorridorPois(
* there, and boxing that would search open sea, or the countryside under a flight
* path, and offer what it found as being on the way. Each pair is projected onto the
* spine and the stretch between the two is left out of the search, and a hit that
* lands in it is dropped. Memoised by the caller: the array's identity is what
* recreates `search`, so a fresh one every render would recreate it every render.
* lands in it is dropped.
*/
gaps?: { from: LatLng; to: LatLng }[]
},
): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
const gaps = options?.gaps
const gapKey = (gaps ?? []).map(g => `${g.from.lat.toFixed(5)},${g.from.lng.toFixed(5)}>${g.to.lat.toFixed(5)},${g.to.lng.toFixed(5)}`).join(';')
const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -150,9 +150,33 @@ export function useCorridorPois(
// Only the TILE line is cut, never the spine: every `alongKm` in a result is a
// distance along the spine, and so is every entry in `stopsAlongKm`. Cutting the
// spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain. The MCP corridor tool cuts the same way.
const ridden = riddenRanges(spine, gaps ?? [])
const allTiles = drivenPieces(spine, ridden, window).flatMap(piece => corridorTiles(piece, widthKm))
// wrong position in the day's chain.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
// The ridden stretches, as kilometres along the spine, in order.
const skipped = (gaps ?? [])
.map(gap => {
const a = projectOntoRoute(gap.from, spine)?.alongKm
const b = projectOntoRoute(gap.to, spine)?.alongKm
return a === undefined || b === undefined ? null : { fromKm: Math.min(a, b), toKm: Math.max(a, b) }
})
.filter((g): g is { fromKm: number; toKm: number } => g !== null && g.toKm > g.fromKm)
.sort((a, b) => a.fromKm - b.fromKm)
const inGap = (alongKm: number): boolean => skipped.some(g => alongKm > g.fromKm + 0.05 && alongKm < g.toKm - 0.05)
// The tile line cut around the gaps: one piece per driven stretch.
const pieces: LatLng[][] = []
if (skipped.length) {
const start = window?.fromKm ?? 0
const end = window?.toKm ?? Number.POSITIVE_INFINITY
let cursor = start
for (const gap of skipped) {
if (gap.fromKm > cursor) pieces.push(sliceAtMeters(spine, cursor * 1000, Math.min(gap.fromKm, end) * 1000))
cursor = Math.max(cursor, gap.toKm)
}
if (cursor < end) pieces.push(Number.isFinite(end) ? sliceAtMeters(spine, cursor * 1000, end * 1000) : sliceAtMeters(spine, cursor * 1000, Number.MAX_SAFE_INTEGER))
} else {
pieces.push(tileLine)
}
const allTiles = pieces.filter(piece => piece.length > 1).flatMap(piece => corridorTiles(piece, widthKm))
const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
@@ -197,7 +221,7 @@ export function useCorridorPois(
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm || inRiddenRange(ridden, hit.alongKm)) continue
if (!hit || hit.offRouteKm > widthKm || inGap(hit.alongKm)) continue
seen.set(poi.osm_id, {
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
@@ -264,7 +288,10 @@ export function useCorridorPois(
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gaps])
// gapKey stands in for `gaps`: the same terminals mean the same gaps, and a fresh
// array every render would make every search a new function.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gapKey])
useEffect(() => () => abortRef.current?.abort(), [])
@@ -30,8 +30,8 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7, dayNumber: 1, date: null, title: null,
stops: [
{ assignmentId: 1, ownerDayId: 7, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, ownerDayId: 7, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
],
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
@@ -83,7 +83,7 @@ beforeEach(() => {
describe('useFollowTrack', () => {
it('clears the original leg when an automatic pause splits its displayed days', async () => {
const [a, b] = day().stops.map(s => ({ ...s, ownerDayId: 1 }))
const [a, b] = day().stops
const end = { ...a, assignmentId: -10, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } }
const start = { ...end, assignmentId: -11, automaticNight: { phase: 'start' as const, fromDayNumber: 1 } }
const split = routes([
@@ -239,45 +239,6 @@ describe('useFollowTrack', () => {
expect(result.current.error).toBeNull()
})
it('FE-FOLLOWHOOK-013: the hotel a day sets out from and comes back to is no stop the track is fitted between', async () => {
const [a, b] = day().stops
const bookend = (phase: 'morning' | 'evening', ownerIndex: number) => ({
...a, assignmentId: phase === 'morning' ? -6_000_000_014 : -6_000_000_015, ownerIndex, placeId: 900, lat: 51.9, lng: 13.5, stopType: 'hotel',
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
})
answersStraight()
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day({ stops: [bookend('morning', 0), a, b, bookend('evening', 2)] })]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
// One leg, A to B: a via fitted to the drive to or from the hotel would be filed behind
// a stop it does not leave from.
const [, written, legs] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
expect(legs).toEqual([0])
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
await act(async () => { await result.current.clear() })
expect(vias.addMany).toHaveBeenLastCalledWith(7, [], [0], null)
})
it('FE-FOLLOWHOOK-014: nor is a terminal, and a day a night drive split over two cards is fitted whole', async () => {
const [a, b] = day().stops
const terminal = (role: 'departure' | 'arrival') => ({
...a, assignmentId: role === 'departure' ? -3_000_000_140 : -3_000_000_141, ownerIndex: 1, placeId: -70,
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null },
})
const next = { ...b, assignmentId: 3, ownerDayId: 8, ownerIndex: 0, placeId: 30, name: 'C' }
const cards = routes([
day({ stops: [a, terminal('departure'), terminal('arrival')] }),
day({ dayId: 8, dayNumber: 2, stops: [b, next] }),
])
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], cards, vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.clear() })
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null)
})
it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => {
answersStraight()
const vias = viasStub()
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
import type { LatLng } from './corridor'
import { isStoredStop } from '@trek/shared/roadtrip'
import type { RoadtripRoutes } from './useRoadtripRoutes'
import type { RoadtripVias } from './useRoadtripVias'
@@ -100,20 +99,13 @@ export function useFollowTrack(
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
const abort = useRef<AbortController | null>(null)
/**
* The day's stored stops, in their stored order, wherever the cards draw them.
*
* A via is filed behind a stored stop, by its index (`legs` in the plan is that index),
* so the track is fitted between those and nothing else: not the markers a day window
* adds, not a terminal and not the hotel a day starts or ends at, each of which would
* shift every via behind it one leg on. Read across every card because a night drive
* draws some of them on the next one.
*/
const stops = useMemo<LatLng[]>(() => routes.days
.flatMap(d => d.stops)
.filter(s => isStoredStop(s) && s.ownerDayId === dayId)
.sort((a, b) => a.ownerIndex - b.ownerIndex)
.map(s => ({ lat: s.lat, lng: s.lng })), [routes.days, dayId])
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
const stops = useMemo<LatLng[]>(() => {
const original = day?.automaticSchedule
? routes.days.flatMap(d => d.stops).filter(s => !s.automaticNight && s.ownerDayId === dayId).sort((a, b) => a.ownerIndex - b.ownerIndex)
: day?.stops ?? []
return original.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day, routes.days, dayId])
/**
* Parsed only while the dialog is open.
@@ -1,30 +0,0 @@
import { hotelBookendsOn } from '@trek/shared/roadtrip'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
/** The preference the switch is stored under, spelled once for both surfaces. */
export const HOTEL_BOOKENDS_KEY = 'roadtrip_hotel_bookends'
/** Whether a trip's days start and end at the stay, and the way to flip it. */
export interface HotelBookendsSwitch {
on: boolean
/** Absent for a reader who may not change it, or while the trip's settings are not in. */
toggle?: () => void
}
/**
* The switch that seats a booked night at both ends of its days, as the desktop settings
* and the phone's driving figures both offer it.
*
* One hook so the two cannot disagree about what "on" means or where it is written. The
* value is the shared rule's own reading (`hotelBookendsOn`: missing is off), the one the
* drive is planned with, and the write is the save the caller holds. On both surfaces that
* is the planner's `saveRoadtripLimit`, so a flip on the phone goes through the same repo,
* the same offline queue and the same broadcast as one on the desktop.
*/
export function useHotelBookends(
save: ((key: string, value: boolean) => void) | undefined,
tripId?: number | string | null,
): HotelBookendsSwitch {
const on = useRoadtripSettings(hotelBookendsOn, tripId)
return { on, toggle: save ? () => save(HOTEL_BOOKENDS_KEY, !on) : undefined }
}
@@ -313,37 +313,4 @@ describe('useRoadtripCorridor', () => {
expect(result.current.insertIndexFor(beforeFirstStop)).toBe(1)
})
})
it('FE-ROADTRIP-CORRIDORSTATE-038: the leg between a departure terminal and its arrival is no section', () => {
// The search leaves the ride out and drops every hit inside it, so a section around
// its midpoint could only ever come back empty. The two terminals stay offered.
const terminal = (role: 'departure' | 'arrival', index: number, lat: number, lng: number) => ({
assignmentId: -3000000014 - index, ownerDayId: 1, ownerIndex: index, placeId: -7, name: role, lat, lng,
time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
})
const spine = [
{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }, { lat: 52.0, lng: 14.5 },
]
useCorridorPois.mockReturnValue({ ...searchWith([]), spine })
const [first, last] = day().stops
const flightDay = day({
stops: [
first,
terminal('departure', 1, 53.0, 11.0),
terminal('arrival', 2, 52.5, 13.4),
{ ...last, assignmentId: 3, ownerIndex: 3, lat: 52.0, lng: 14.5 },
],
geometry: spine.map(p => [p.lat, p.lng] as [number, number]),
})
const { result } = renderHook(() => useRoadtripCorridor(routes([flightDay])))
// On the old code the list also carries 'leg:1', the midpoint of the flight.
expect(result.current.anchors.map(a => `${a.kind}:${a.index}`))
.toEqual(['stop:0', 'leg:0', 'stop:1', 'stop:2', 'leg:2', 'stop:3'])
// The same ride is what the search is told to leave out.
const asked = useCorridorPois.mock.calls[useCorridorPois.mock.calls.length - 1]
expect(asked[3].gaps).toEqual([{ index: 1, from: { lat: 53.0, lng: 11.0 }, to: { lat: 52.5, lng: 13.4 } }])
})
})
@@ -3,7 +3,7 @@ import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCor
import type { CorridorBudget } from './corridorSearchModel'
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
import type { SectionAnchor } from './roadtripModel'
import { projectOntoRoute, rideGaps, type LatLng } from './corridor'
import { projectOntoRoute, type LatLng } from './corridor'
import { useVehicleRange } from './useVehicleRange'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
@@ -121,9 +121,19 @@ export function useRoadtripCorridor(
// The rides on this day: between a departure terminal and its arrival the car is not
// on the road, so that stretch of the line is not searched (#2428).
const rides = useMemo(() => (day ? rideGaps(day.stops) : []), [day])
const gaps = useMemo(() => {
if (!day) return []
const out: { from: LatLng; to: LatLng }[] = []
day.stops.forEach((stop, i) => {
const next = day.stops[i + 1]
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
out.push({ from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } })
}
})
return out
}, [day])
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps: rides }), [options, rides]))
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps }), [options, gaps]))
/**
* Whether what the panel looks for has been decided yet.
@@ -197,14 +207,8 @@ export function useRoadtripCorridor(
* Resolved here rather than stored: `section` names an anchor, and its kilometre figure
* is looked up fresh, so changing the corridor width does not move the point the list
* is narrowed around.
*
* No break is planned in the middle of a ride: the search never covers it, so the leg
* between a departure terminal and its arrival is not offered as a section.
*/
const anchors = useMemo(
() => sectionAnchors(stopsAlongKm).filter(a => a.kind !== 'leg' || !rides.some(r => r.index === a.index)),
[stopsAlongKm, rides],
)
const anchors = useMemo(() => sectionAnchors(stopsAlongKm), [stopsAlongKm])
const anchorKm = useMemo(() => {
if (!section || !day || section.dayId !== day.dayId) return null
return anchors.find(a => a.kind === section.kind && a.index === section.index)?.alongKm ?? null
@@ -1,172 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Assignment, AssignmentsMap, Day } from '../../types'
// #2455: "the road trip plan should align with the days ordering". The road trip drives a
// day in its stored order (order_index); the Days list draws the same stored day by time.
// Where the two agree the rail must read exactly like the list, and where a day is stored
// against its own times the rail must at least keep every stop on its own card.
const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
class RoutingRefusedError extends Error {
constructor(readonly status: number, readonly retryAfterMs: number | null) {
super('refused')
this.name = 'RoutingRefusedError'
}
get isRateLimit(): boolean { return this.status === 429 || this.status === 503 }
}
return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
})
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
import { useRoadtripRoutes } from './useRoadtripRoutes'
import { getMergedItems } from '../../utils/dayMerge'
interface Visit {
id: number
name: string
at: [number, number]
/** The visit's start, as the day list shows it. */
time?: string | null
}
const day = (id: number, number: number): Day =>
({ id, day_number: number, date: null, title: null }) as unknown as Day
/**
* A stored visit shaped the way the server hands it over: `place.place_time` is the
* visit's own start folded over the place's (COALESCE in AssignmentsService), which is
* the field the Days list sorts by.
*/
const visit = (v: Visit, orderIndex: number, dayId: number): Assignment =>
({
id: v.id,
day_id: dayId,
place_id: v.id * 10,
order_index: orderIndex,
assignment_time: v.time ?? null,
assignment_end_time: null,
leg_transport_mode: null,
incoming_leg_transport_mode: null,
place: {
id: v.id * 10,
name: v.name,
lat: v.at[0],
lng: v.at[1],
place_time: v.time ?? null,
end_time: null,
duration_minutes: 30,
stop_type: null,
},
}) as unknown as Assignment
/** A day as stored: `order_index` is the position in the array. */
const stored = (dayId: number, visits: Visit[]): AssignmentsMap =>
({ [String(dayId)]: visits.map((v, i) => visit(v, i, dayId)) }) as unknown as AssignmentsMap
/** The places of one day in the order the Days list draws them (DayPlanSidebar, MPlanTimeline). */
const daysListOrder = (assignments: AssignmentsMap, dayId: number): string[] =>
getMergedItems({
dayAssignments: [...(assignments[String(dayId)] ?? [])].sort((a, b) => a.order_index - b.order_index),
dayNotes: [],
dayTransports: [],
dayId,
})
.filter(item => item.type === 'place')
.map(item => (item.data as Assignment).place!.name)
type Card = { dayId: number; stops: { ownerDayId: number; name: string; automaticNight?: unknown }[] }
/** What one card of the rail draws, by name. */
const cardOf = (days: Card[], dayId: number): string[] =>
days.find(d => d.dayId === dayId)?.stops.filter(s => !s.automaticNight).map(s => s.name) ?? []
/** The cards that draw a stop stored on `dayId`. */
const cardsHolding = (days: Card[], dayId: number): number[] =>
days.filter(d => d.stops.some(s => !s.automaticNight && s.ownerDayId === dayId)).map(d => d.dayId)
beforeEach(() => {
calculateRouteWithLegs.mockReset()
// Twenty minutes and 20 km between any two waypoints, one leg per pair, the way OSRM answers.
calculateRouteWithLegs.mockImplementation(async (waypoints: { lat: number; lng: number }[]) => ({
coordinates: waypoints.map(w => [w.lat, w.lng] as [number, number]),
distance: 20000 * (waypoints.length - 1),
duration: 1200 * (waypoints.length - 1),
legs: waypoints.slice(1).map(() => ({ distance: 20000, duration: 1200, text: '20 km' })),
}))
})
describe('the road trip reads a day the way the Days list does (#2455)', () => {
it('FE-ROADTRIP-ORDER-001: a day arranged by hand drives in the order the list shows', async () => {
const days = [day(1, 1)]
// Handed over out of array order on purpose: order_index decides, not the array.
const assignments = {
'1': [
visit({ id: 3, name: 'Schwerin', at: [53.63, 11.41] }, 2, 1),
visit({ id: 1, name: 'Lübeck', at: [53.87, 10.69] }, 0, 1),
visit({ id: 2, name: 'Wismar', at: [53.89, 11.46], time: '11:00' }, 1, 1),
],
} as unknown as AssignmentsMap
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(daysListOrder(assignments, 1)).toEqual(['Lübeck', 'Wismar', 'Schwerin'])
expect(cardOf(result.current.days, 1)).toEqual(daysListOrder(assignments, 1))
})
it('FE-ROADTRIP-ORDER-002: a stop stored behind later starts stays on its own card, flagged late', async () => {
// What a Days drop onto a day header used to leave behind, and what a trip moved
// that way still holds: a 10:00 stop stored after a 14:00 one. The schedule reaches
// it late on day one; the rail used to take that for a night and open day two with it.
const days = [day(1, 1), day(2, 2)]
const assignments = {
...stored(1, [
{ id: 1, name: 'Lübeck', at: [53.87, 10.69], time: '09:00' },
{ id: 2, name: 'Wismar', at: [53.89, 11.46], time: '11:00' },
{ id: 3, name: 'Rostock', at: [54.09, 12.10], time: '14:00' },
{ id: 4, name: 'Travemünde', at: [53.96, 10.87], time: '10:00' },
]),
...stored(2, [
{ id: 5, name: 'Stralsund', at: [54.31, 13.09] },
{ id: 6, name: 'Greifswald', at: [54.09, 13.38] },
]),
} as AssignmentsMap
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(cardOf(result.current.days, 1)).toEqual(['Lübeck', 'Wismar', 'Rostock', 'Travemünde'])
expect(cardsHolding(result.current.days, 1)).toEqual([1])
expect(cardOf(result.current.days, 2)).toEqual(['Stralsund', 'Greifswald'])
const dayOne = result.current.days.find(d => d.dayId === 1)!
expect(dayOne.schedule.warnings).toContainEqual(expect.objectContaining({ index: 3, code: 'late' }))
expect(result.current.days.find(d => d.dayId === 2)!.spills).toEqual([])
})
it('FE-ROADTRIP-ORDER-003: a day stored against its times is not torn across two cards', async () => {
// However it came about (a cross-day move, a start set on the place through the MCP
// tools, a trip planned before the time sort), day one keeps its card: it was demoted
// to a one-stop placeholder while its other stops opened day two.
const days = [day(1, 1), day(2, 2)]
const assignments = {
...stored(1, [
{ id: 1, name: 'Lübeck', at: [53.87, 10.69], time: '14:00' },
{ id: 2, name: 'Wismar', at: [53.89, 11.46], time: '10:00' },
{ id: 3, name: 'Rostock', at: [54.09, 12.10], time: '16:00' },
]),
...stored(2, [
{ id: 4, name: 'Stralsund', at: [54.31, 13.09] },
{ id: 5, name: 'Greifswald', at: [54.09, 13.38] },
]),
} as AssignmentsMap
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(cardOf(result.current.days, 1)).toEqual(['Lübeck', 'Wismar', 'Rostock'])
expect(cardsHolding(result.current.days, 1)).toEqual([1])
expect(cardOf(result.current.days, 2)).toEqual(['Stralsund', 'Greifswald'])
expect(result.current.quietDays.map(d => d.dayId)).toEqual([])
})
})
@@ -1,7 +1,6 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { Accommodation, Assignment, AssignmentsMap, Day, Reservation, Settings } from '../../types'
import type { RoadtripStop } from './useRoadtripRoutes'
import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
// Hoisted together with the mock: the module factory runs before the file body, so a
// class declared down there would not exist yet when the hook does its `instanceof`.
@@ -15,17 +14,10 @@ const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
}
return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
})
// The rest of the module stays real: which engine a leg belongs to is decided there, and
// the leg router has to agree with it rather than with a copy.
vi.mock('../Map/RouteCalculator', async importOriginal => ({
...(await importOriginal<typeof import('../Map/RouteCalculator')>()),
calculateRouteWithLegs,
RoutingRefusedError,
}))
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
import { useRoadtripRoutes } from './useRoadtripRoutes'
import { DEFAULT_SETTINGS, useSettingsStore } from '../../store/settingsStore'
import { useTripStore } from '../../store/tripStore'
import { lineMetres } from './corridor'
const HAMBURG: [number, number] = [53.5511, 9.9937]
@@ -777,66 +769,6 @@ describe('useRoadtripRoutes', () => {
expect(inboundDry.lat).toBeGreaterThan(BERLIN[0])
expect(inboundDry.lat).toBeLessThan(LUENEBURG[0])
})
it('FE-ROADTRIP-ROUTES-049: switching what the trip avoids asks for the join again, with the new classes', async () => {
// The seam round skipped every join it already had an answer for, whatever that
// answer had been asked with, so the drive between two days kept its old road and
// its old minutes until the page was loaded again.
//
// The clock the requests are spaced by is held still and moved by hand, so neither
// round waits for real time between its requests.
let now = 10_000
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now)
try {
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
expect(askedFor(LUENEBURG, BERLIN)[0][1]).toMatchObject({ avoid: [] })
now += 10_000
setting({ roadtrip_avoid: 'toll' })
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
expect(askedFor(LUENEBURG, BERLIN)[1][1]).toMatchObject({ profile: 'driving', avoid: ['toll'] })
} finally {
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()
}
})
})
})
@@ -999,49 +931,6 @@ describe('a booking the traveller rides (#2428)', () => {
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 })
})
it('FE-ROADTRIP-ROUTES-044: an edited timetable changes the ride minutes without a routing round, and the chain is not late for it', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, { id: 2, at: BERLIN, dwell: 0 }]
const { result, rerender } = renderHook(
({ reservations }) => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], reservations),
{ initialProps: { reservations: [flight()] } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].legs[1]).toMatchObject({ mode: 'flight', duration: 70 * 60 })
// The same terminals, the same roads: the landing is now forty minutes after take-off.
rerender({ reservations: [flight({ reservation_end_time: '14:00' })] })
await waitFor(() => expect(result.current.days[0].legs[1]?.duration).toBe(40 * 60))
const d = result.current.days[0]
expect(d.legs[1]?.durationText).toBe('40 min')
expect(d.schedule.entries.map(e => e.arrival)).toEqual(['09:00', '12:20', '14:00', '15:00'])
expect(d.schedule.warnings.filter(w => w.code === 'late')).toEqual([])
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-ROUTES-045: an overnight ride is the join between its cards, and a moved landing moves it too', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
const days = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
}
const overnight = (landing: string) => flight({ reservation_time: '22:00', reservation_end_time: landing, end_day_id: 2 })
const { result, rerender } = renderHook(
({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], reservations),
{ initialProps: { reservations: [overnight('07:00')] } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[1]?.arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600 })
// The ride is derived, not fetched: the router saw the two roads and nothing else.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
rerender({ reservations: [overnight('06:30')] })
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.duration).toBe(8.5 * 3600))
expect(result.current.days[1].schedule.entries[0].arrival).toBe('06:30')
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or a taxi, changes nothing', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
@@ -1079,424 +968,3 @@ describe('a booking the traveller rides (#2428)', () => {
expect(result.current.lines).toHaveLength(3)
})
})
/**
* One leg asked for again, the way the rail asks for it.
*
* The picker of other ways and the check behind a choice both route a single leg on
* demand. Asked by anything but the rail's own rules, the offers described a drive the rail
* was not on: OSRM with nothing avoided beside a day Valhalla had weighed, under the trip's
* profile rather than the leg's own mode.
*/
describe('a leg asked for again on demand', () => {
beforeEach(() => {
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
})
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
const stopOn = (ownerDayId: number, ownerIndex: number, at: [number, number], over: Partial<RoadtripStop> = {}) =>
({ assignmentId: ownerDayId * 10 + ownerIndex, ownerDayId, ownerIndex, lat: at[0], lng: at[1], legMode: null, incomingLegMode: null, ...over }) as RoadtripStop
it('FE-ROADTRIP-ROUTES-046: a leg inside one day is asked with that day\'s mode, the trip\'s classes and its own day', async () => {
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll' } })) })
const days = [day(1, 1, { default_transport_mode: 'driving' })]
// The trip-wide profile says walking; the day's own default is what the run used.
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]), 'walking'))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs.mock.calls[0][1]).toMatchObject({ profile: 'driving', avoid: ['toll'], dayId: 1 })
const [from, to] = result.current.days[0].stops
const router = result.current.legRouter!(from, to, 1)
expect(router).toMatchObject({ mode: 'driving', avoid: ['toll'], engine: 'valhalla' })
calculateRouteWithLegs.mockClear()
calculateRouteWithLegs.mockResolvedValue({ ...routed(2), hasFerry: true })
const answer = await router.route([{ lat: 53, lng: 11 }], new AbortController().signal)
expect(calculateRouteWithLegs).toHaveBeenCalledWith(
[{ lat: HAMBURG[0], lng: HAMBURG[1] }, { lat: 53, lng: 11 }, { lat: BERLIN[0], lng: BERLIN[1] }],
expect.objectContaining({ profile: 'driving', tripId: 7, dayId: 1, avoid: ['toll'] }),
)
expect(answer).toMatchObject({ coordinates: [HAMBURG, BERLIN], distance: 100000, duration: 3600, hasFerry: true, fellBack: false })
})
it('FE-ROADTRIP-ROUTES-047: a seam is asked under the card it arrives on, and a leg nothing weighs is OSRM\'s', () => {
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'motorway' } })) })
const days = [day(1, 1, { default_transport_mode: 'cycling' }), day(2, 2, { default_transport_mode: 'driving' })]
const { result } = renderHook(() => useRoadtripRoutes(7, days, {} as AssignmentsMap))
const lastOfDay1 = stopOn(1, 3, LUENEBURG)
const firstOfDay2 = stopOn(2, 0, BERLIN)
// Across the seam: day 2's default, so the car and the classes it avoids.
expect(result.current.legRouter!(lastOfDay1, firstOfDay2, 2)).toMatchObject({ mode: 'driving', avoid: ['motorway'], engine: 'valhalla' })
// Inside day 1: day 1's bicycle, which nothing is weighed against.
expect(result.current.legRouter!(stopOn(1, 2, HAMBURG), lastOfDay1, 2)).toMatchObject({ mode: 'cycling', avoid: [], engine: 'osrm' })
// A stop's own mode wins over any default, and a plugin prices its own leg.
expect(result.current.legRouter!(stopOn(2, 0, BERLIN, { legMode: 'plugin:ev/fast' }), stopOn(2, 1, HAMBURG), 2))
.toMatchObject({ mode: 'plugin:ev/fast', avoid: [], engine: 'plugin' })
})
it('FE-ROADTRIP-ROUTES-048: a day the stand-in engine drove while avoiding says the avoidance did not happen', async () => {
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll,ferry' } })) })
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), avoidance: { asked: ['ferry', '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))
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([])
})
})
/**
* FE-ROADTRIP-ROUTES-052..061: a booked night at both ends of the days around it.
*
* The first cases here that hand the hook the trip's stays. What is pinned is what goes to
* the router: the drive from the hotel slept in and to tonight's is part of the day's own
* run, a night spent at one hotel asks for nothing, no via bends the hotel's legs, and with
* the switch off the days are the stored ones and nothing else.
*/
describe('a booked night at both ends of its days', () => {
const GETAWAY: [number, number] = [-33.71, 150.31]
const LOOKOUT: [number, number] = [-33.73, 150.35]
const FALLS: [number, number] = [-33.65, 150.38]
const WALLINGA: [number, number] = [-34.1, 150.9]
const HOTEL: [number, number] = [45.07, 7.68]
const P: [number, number][] = [1, 2, 3, 4, 5, 6].map(n => [45 + n * 0.1, 7 + n * 0.1])
const VIA: [number, number] = [44.5, 7.5]
/** A stay whose place is the one stop spec `stopId` stands for (`place_id` is id * 10). */
const stayOf = (id: number, stopId: number, start: number, end: number, at: [number, number], over: Record<string, unknown> = {}) =>
({
id, trip_id: 7, place_id: stopId * 10, start_day_id: start, end_day_id: end,
check_in: null, check_out: '10:00', place_name: `Stay ${id}`, place_lat: at[0], place_lng: at[1],
...over,
}) as unknown as Accommodation
const booking = (id: number, accommodationId: number) =>
({ id, trip_id: 7, title: 'Motel', type: 'hotel', status: 'confirmed', accommodation_id: accommodationId }) as unknown as Reservation
/** An hour and 100 km for whatever pair the router is handed. */
const hourly = (points: { lat: number; lng: number }[]) => ({
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
distance: 100000 * (points.length - 1),
duration: 3600 * (points.length - 1),
legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
})
/** Every run the router was asked for, as its waypoints' latitudes. */
const asked = () => calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
const shape = (stops: RoadtripStop[]) =>
stops.map(s => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.name))
const switchTo = (on: boolean) =>
act(() => { useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_hotel_bookends: on } as never }) })
beforeEach(() => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
switchTo(true)
})
afterEach(() => act(() => {
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
useTripStore.setState({ places: [] })
}))
/** A check-in with two places, then a transfer day that is only the next check-in. */
const cam = () => ({
days: [day(1, 1), day(2, 2), day(3, 3)],
assignments: {
...map(1, [{ id: 1, at: GETAWAY }, { id: 2, at: LOOKOUT }, { id: 3, at: FALLS }]),
...map(2, [{ id: 4, at: WALLINGA }]),
} as AssignmentsMap,
stays: [stayOf(1, 1, 1, 2, GETAWAY, { check_in: '14:00' }), stayOf(2, 4, 2, 3, WALLINGA, { check_in: '15:00' })],
})
/** Three nights in one hotel with two places on every day before the check-out. */
const simeon = () => ({
days: [day(1, 1), day(2, 2), day(3, 3), day(4, 4)],
assignments: {
...map(1, [{ id: 10, at: HOTEL }, { id: 11, at: P[0] }, { id: 12, at: P[1] }]),
...map(2, [{ id: 13, at: P[2] }, { id: 14, at: P[3] }]),
...map(3, [{ id: 15, at: P[4] }, { id: 16, at: P[5] }]),
} as AssignmentsMap,
stays: [stayOf(5, 10, 1, 4, HOTEL, { check_in: '15:00' })],
})
it('FE-ROADTRIP-ROUTES-052: the check-in day drives back to the stay, the transfer day sets off from it', async () => {
const { days, assignments, stays } = cam()
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, [booking(90, 1)]))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days.map(d => shape(d.stops))).toEqual([
['Stop 1', 'Stop 2', 'Stop 3', 'evening:1'],
['morning:1', 'Stop 4'],
])
// One run per day, the hotel legs inside it.
expect(asked()).toEqual([
[GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]],
[GETAWAY[0], WALLINGA[0]],
])
expect(result.current.days[1].stops[0].bookend).toMatchObject({ checkingOut: true, checkOut: '10:00', reservationId: 90 })
expect(result.current.days[0].legs).toHaveLength(3)
// Nothing numbered changes: the hotel is a service stop, the day's own stops are counted.
expect(result.current.totalStops).toBe(4)
// The check-out day with nothing of its own is no card.
expect(result.current.days.map(d => d.dayId)).toEqual([1, 2])
})
it('FE-ROADTRIP-ROUTES-053: every day between the nights starts and ends at the hotel', async () => {
const { days, assignments, stays } = simeon()
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days.map(d => shape(d.stops))).toEqual([
['Stop 10', 'Stop 11', 'Stop 12', 'evening:5'],
['morning:5', 'Stop 13', 'Stop 14', 'evening:5'],
['morning:5', 'Stop 15', 'Stop 16', 'evening:5'],
])
expect(asked()).toEqual([
[HOTEL[0], P[0][0], P[1][0], HOTEL[0]],
[HOTEL[0], P[2][0], P[3][0], HOTEL[0]],
[HOTEL[0], P[4][0], P[5][0], HOTEL[0]],
])
// The check-out day has nothing of its own and is not driven.
expect(result.current.days.map(d => d.dayId)).toEqual([1, 2, 3])
})
it('FE-ROADTRIP-ROUTES-054: with the days connected, a night spent at one hotel asks the router nothing', async () => {
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_connect_days: true } })) })
const { days, assignments, stays } = cam()
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
// The two day runs and nothing else: no seam from the Getaway to the Getaway.
expect(asked()).toEqual([
[GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]],
[GETAWAY[0], WALLINGA[0]],
])
expect(result.current.days[1].arrivingLeg).toBeFalsy()
expect(result.current.totalDistance).toBe(400000)
})
it('FE-ROADTRIP-ROUTES-055: a via behind the last place of a day does not bend the drive to tonight’s stay', async () => {
const { days, assignments, stays } = cam()
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 2, sequence: 0, lat: VIA[0], lng: VIA[1] }] }
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', vias, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(asked()[0]).toEqual([GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]])
expect(asked().flat()).not.toContain(VIA[0])
})
it('FE-ROADTRIP-ROUTES-056: no via leaves the morning hotel; one after the first place stays after it', async () => {
const { days, assignments, stays } = simeon()
const vias = { 2: [{ id: 1, day_id: 2, after_order_index: 0, sequence: 0, lat: VIA[0], lng: VIA[1] }] }
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', vias, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(asked()[1]).toEqual([HOTEL[0], P[2][0], VIA[0], P[3][0], HOTEL[0]])
})
it('FE-ROADTRIP-ROUTES-057: a transfer day with nothing stored drives from one stay to the other', async () => {
const days = [day(1, 1), day(2, 2), day(3, 3)]
const assignments = { ...map(1, [{ id: 1, at: GETAWAY }, { id: 2, at: LOOKOUT }]) } as AssignmentsMap
const stays = [stayOf(1, 1, 1, 2, GETAWAY), stayOf(2, 4, 2, 3, WALLINGA)]
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
const transfer = result.current.days.find(d => d.dayId === 2)!
expect(shape(transfer.stops)).toEqual(['morning:1', 'evening:2'])
expect(transfer.stops.map(s => s.bookend && [s.bookend.checkingOut, s.bookend.checkingIn])).toEqual([[true, false], [false, true]])
expect(asked()).toContainEqual([GETAWAY[0], WALLINGA[0]])
})
it('FE-ROADTRIP-ROUTES-058: switched off, the days are the stored ones, and back on and off again they are again', async () => {
switchTo(false)
const { days, assignments, stays } = simeon()
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
const stored = result.current.days.map(d => d.stops)
expect(stored.flat().some(s => s.bookend)).toBe(false)
expect(asked()).toEqual([
[HOTEL[0], P[0][0], P[1][0]],
[P[2][0], P[3][0]],
[P[4][0], P[5][0]],
])
switchTo(true)
await waitFor(() => expect(result.current.days[1]?.stops[0]?.bookend?.phase).toBe('morning'))
await waitFor(() => expect(result.current.loading).toBe(false))
switchTo(false)
await waitFor(() => expect(result.current.days.flatMap(d => d.stops).some(s => s.bookend)).toBe(false))
expect(result.current.days.map(d => d.stops)).toEqual(stored)
})
it('FE-ROADTRIP-ROUTES-059: linking a booking to the stay changes the hotel row, not the road', async () => {
const { days, assignments, stays } = cam()
const { result, rerender } = renderHook(
({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, reservations),
{ initialProps: { reservations: [] as Reservation[] } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[1].stops[0].bookend?.reservationId).toBeNull()
const calls = calculateRouteWithLegs.mock.calls.length
rerender({ reservations: [booking(91, 1), booking(90, 1)] })
// The earliest booking of the stay, and the router is not asked again.
await waitFor(() => expect(result.current.days[1].stops[0].bookend?.reservationId).toBe(90))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(calls)
})
it('FE-ROADTRIP-ROUTES-060: a hotel whose pin was moved stands where its place is now, not where the stay row last saw it', async () => {
// The stay rows are fetched on a stay's own edit only, so after the pin moved they still
// carry the old spot while the visit is already at the new one. Seated from the row,
// the check-in day drove from the hotel's stop to its old spot and every morning set
// off from there, while the server, which joins the place afresh, planned neither.
const MOVED: [number, number] = [-33.8, 150.2]
const days = [day(1, 1), day(2, 2), day(3, 3)]
const assignments = {
...map(1, [{ id: 1, at: MOVED }]),
...map(2, [{ id: 2, at: LOOKOUT }]),
} as AssignmentsMap
const stays = [stayOf(1, 1, 1, 3, GETAWAY)]
act(() => {
useTripStore.setState({ places: [{ id: 10, trip_id: 7, name: 'Getaway Motel', lat: MOVED[0], lng: MOVED[1] }] as never })
})
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
// The check-in day is its hotel alone, with no drive to a second copy of it.
expect(result.current.days.map(d => shape(d.stops))).toEqual([
['Stop 1'],
['morning:1', 'Stop 2', 'evening:1'],
])
expect(result.current.days[1].stops[0]).toMatchObject({ lat: MOVED[0], lng: MOVED[1], name: 'Getaway Motel' })
expect(asked()).toEqual([[MOVED[0], LOOKOUT[0], MOVED[0]]])
})
it('FE-ROADTRIP-ROUTES-061: the drive out of the morning hotel goes the way the first place is reached from it', async () => {
// The mode the day plan draws that leg in, and the one the server routes it in.
const { days, stays } = simeon()
const assignments = {
...map(1, [{ id: 10, at: HOTEL }, { id: 11, at: P[0] }, { id: 12, at: P[1] }]),
...map(2, [{ id: 13, at: P[2], incoming: 'walking' }, { id: 14, at: P[3] }]),
...map(3, [{ id: 15, at: P[4] }, { id: 16, at: P[5] }]),
} as AssignmentsMap
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
await waitFor(() => expect(result.current.loading).toBe(false))
const runs = calculateRouteWithLegs.mock.calls.map(c => [c[1].profile, c[0].map((p: { lat: number }) => p.lat)])
expect(runs).toContainEqual(['walking', [HOTEL[0], P[2][0]]])
expect(runs).toContainEqual(['driving', [P[2][0], P[3][0], HOTEL[0]]])
expect(runs).toContainEqual(['driving', [HOTEL[0], P[4][0], P[5][0], HOTEL[0]]])
})
})
/**
* FE-ROADTRIP-ROUTES-062..063: a flight on the day the next stay begins.
*
* Trip 45 on the dev instance: a night in Hamburg, then LH 2078 to Munich at 15:15 on the day
* the Munich hotel is checked into from 15:00. And a move between two stays by a flight saved
* without its airports. What is pinned is the day the rail reads and what goes to the router.
*/
describe('a flight on the day the next stay begins', () => {
const ATLANTIC: [number, number] = [53.5573, 10.0056]
const BAYERISCHER_HOF: [number, number] = [48.1403, 11.5732]
const HAM: [number, number] = [53.630402, 9.98823]
const MUC: [number, number] = [48.353802, 11.7861]
const GETAWAY: [number, number] = [-33.71, 150.31]
const LOOKOUT: [number, number] = [-33.73, 150.35]
const WALLINGA: [number, number] = [-34.1, 150.9]
const stayOf = (id: number, stopId: number, start: number, end: number, at: [number, number]) =>
({
id, trip_id: 7, place_id: stopId * 10, start_day_id: start, end_day_id: end,
check_in: '15:00', check_out: '11:00', place_name: `Stay ${id}`, place_lat: at[0], place_lng: at[1],
}) as unknown as Accommodation
const lh2078 = (over: Record<string, unknown> = {}) => ({
id: 77,
trip_id: 7,
title: 'LH 2078 HAM-MUC',
type: 'flight',
status: 'pending',
day_id: 3,
end_day_id: 3,
reservation_time: '2026-11-04T15:15',
reservation_end_time: '2026-11-04T17:20',
day_plan_position: 0.5,
endpoints: [
{ role: 'from', sequence: 0, name: 'Hamburg (HAM)', code: 'HAM', lat: HAM[0], lng: HAM[1], timezone: 'Europe/Berlin', local_time: '15:15', local_date: '2026-11-04' },
{ role: 'to', sequence: 1, name: 'Munich (MUC)', code: 'MUC', lat: MUC[0], lng: MUC[1], timezone: 'Europe/Berlin', local_time: '17:20', local_date: '2026-11-04' },
],
...over,
}) as unknown as Reservation
const hourly = (points: { lat: number; lng: number }[]) => ({
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
distance: 100000 * (points.length - 1),
duration: 3600 * (points.length - 1),
legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
})
const asked = () => calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
const shape = (stops: RoadtripStop[]) =>
stops.map(s => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.carrier ? s.carrier.role : s.name))
const switchTo = (on: boolean) =>
act(() => { useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_hotel_bookends: on } as never }) })
beforeEach(() => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
})
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
it('FE-ROADTRIP-ROUTES-062: the Munich hotel waits behind the landing, and no road runs between the two cities, switch on or off', async () => {
const days = [day(1, 1), day(2, 2), day(3, 3), day(4, 4), day(5, 5)]
const assignments = { ...map(1, [{ id: 1, at: ATLANTIC, dwell: 60 }]), ...map(3, [{ id: 2, at: BAYERISCHER_HOF, dwell: 60 }]) } as AssignmentsMap
const stays = [stayOf(33, 1, 1, 3, ATLANTIC), stayOf(34, 2, 3, 5, BAYERISCHER_HOF)]
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, [lh2078()]))
const flightDay = () => result.current.days.find(d => d.dayId === 3)!
// Only ever a road inside one city: every run asked for lies north of 53 or south of 49.
const oneCity = () => asked().every(run => run.every(lat => lat > 53) || run.every(lat => lat < 49))
switchTo(false)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(shape(flightDay().stops)).toEqual(['departure', 'arrival', 'Stop 2'])
expect(oneCity()).toBe(true)
// At the airport by the check-in, and at the hotel from the landing, not late for 15:00.
expect(flightDay().schedule.entries.map(e => e.arrival)).toEqual(['14:15', '17:20', '18:20'])
expect(flightDay().schedule.warnings).toEqual([])
switchTo(true)
await waitFor(() => expect(flightDay()?.stops[0]?.bookend?.phase).toBe('morning'))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(shape(flightDay().stops)).toEqual(['morning:33', 'departure', 'arrival', 'Stop 2'])
expect(asked()).toContainEqual([ATLANTIC[0], HAM[0]])
expect(oneCity()).toBe(true)
})
it('FE-ROADTRIP-ROUTES-063: a flight saved without its airports is the move between two stays, and no road is asked for it', async () => {
switchTo(true)
const days = [day(1, 1), day(2, 2), day(3, 3)]
const assignments = { ...map(1, [{ id: 1, at: GETAWAY }, { id: 3, at: LOOKOUT }]), ...map(2, [{ id: 4, at: WALLINGA }]) } as AssignmentsMap
const stays = [stayOf(1, 1, 1, 2, GETAWAY), stayOf(2, 4, 2, 3, WALLINGA)]
const unlocated = lh2078({ day_id: 2, end_day_id: 2, endpoints: [] })
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, [unlocated]))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days.find(d => d.dayId === 2)!.stops.map(s => s.name)).toEqual(['Stop 4'])
expect(asked().flat()).not.toContain(WALLINGA[0])
})
})

Some files were not shown because too many files have changed in this diff Show More