mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e79d0996ae | |||
| 8936049a81 | |||
| c0b691b8e5 | |||
| aa64572bf0 | |||
| 23aec0d898 |
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.2",
|
||||
"version": "4.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+7
-27
@@ -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
@@ -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(() => {})
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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,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] })
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user