mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f073a04b85 | |||
| 1c05ee4b3a | |||
| 1ff22490f2 | |||
| aa1f76ad11 | |||
| c6a089107f | |||
| cd06f0f7f4 | |||
| e6f66014a5 | |||
| 06bee95ae3 | |||
| 5f77c2cee9 | |||
| cf5920ebe0 | |||
| a0952bd871 | |||
| 3c7f6e69dd | |||
| 30a3f664b1 | |||
| 0a3a993dfc | |||
| bea3af4b45 | |||
| 26ab4a58b1 | |||
| 0887f251ce | |||
| 151e32107f | |||
| 8779529efb | |||
| e1315806c2 | |||
| e64ade010f | |||
| 3062df153f | |||
| 233076030b | |||
| c5ac479915 | |||
| 954758082b | |||
| 83be759c49 | |||
| f3ca0b1a4c | |||
| 302101b186 | |||
| 1639fc6a3e | |||
| 1b63285ad0 | |||
| b56f335978 | |||
| 2f6f921aef | |||
| 436f0740fb | |||
| 1423ba8933 | |||
| 2350c3ae14 | |||
| 362b1ee8d1 | |||
| b5457d9e6a | |||
| a1d6348c5f | |||
| f16698bdb1 | |||
| a910c2a837 | |||
| 0df82185bd | |||
| e8ec3427e5 | |||
| 8d78c988c3 | |||
| ae80ad266d | |||
| 41b14ebc2a | |||
| 05f533dd0d | |||
| e6e520b78f | |||
| 05651eed1a | |||
| 7e7d13c021 | |||
| 514e2e62ee | |||
| 7fa0434570 | |||
| 3a67a8e279 | |||
| cc5957dc6a | |||
| 83d088e842 | |||
| e921799580 | |||
| 34c1dc79b5 | |||
| 451e6d240b | |||
| ab3a81a9eb | |||
| e6daed9403 | |||
| 53a422d22d | |||
| 6124e0b056 | |||
| d52154c765 | |||
| aceb75949b | |||
| 4f5a6bb7d8 | |||
| d609e1e202 | |||
| 6f3977e4c1 | |||
| 615e74aff6 | |||
| bb515ac3cc | |||
| 93dddaa4af | |||
| 1a8d5a3dd9 | |||
| ed8acf4c4b | |||
| a280517ff3 | |||
| 2dd77aaac1 | |||
| 85b5756e89 | |||
| cb85ba915c | |||
| 81292d4d2b | |||
| 757967bf18 | |||
| b0c19bfac1 | |||
| da32eb8a3c | |||
| a44df6cf11 | |||
| e510ff25ab | |||
| 34f3aeee5f | |||
| bdd326163f | |||
| b1baf3baa9 |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.0
|
||||
version: 4.3.3
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.0"
|
||||
appVersion: "4.3.3"
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
|
||||
before first paint AND complies with the prod CSP (script-src 'self'). -->
|
||||
<script src="/theme-boot.js"></script>
|
||||
<!-- Recovers a shell whose entry module cannot load (#2524). Classic for the
|
||||
same reasons, and ahead of the entry, which it has to watch. -->
|
||||
<script src="/shell-guard.js"></script>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.0",
|
||||
"version": "4.3.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -97,7 +97,7 @@
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "8.1.0",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Boot guard for the app shell (#2524).
|
||||
*
|
||||
* Loaded as an external classic script in <head> (NOT a module), like
|
||||
* theme-boot.js, so it runs before the entry module and complies with the
|
||||
* production CSP (script-src 'self').
|
||||
*
|
||||
* The entry module and every chunk it imports statically have to load before
|
||||
* any of the app's own code runs. When one of them does not (a service worker
|
||||
* precache that keeps index.html under a chunk's name, a proxy that hands out a
|
||||
* stale file), the module script fails as a whole: nothing renders, and no error
|
||||
* screen or reload button of the app is there to help. A reload changes nothing
|
||||
* either, since the same precache answers it. This notices exactly that failure
|
||||
* and, while the server answers, loads the page again from the server under a
|
||||
* URL no cache has seen. When a service worker served the page, the app shell
|
||||
* it keeps (the Workbox precache and the worker itself) is thrown away first,
|
||||
* since that is what answered with the broken file. Once per session, so a build
|
||||
* that is broken on the server cannot put the page in a reload loop. Offline
|
||||
* nothing happens: the precache is then the only way the app can start at all.
|
||||
*
|
||||
* It also strips the query parameter such a reload carries, before the app
|
||||
* reads the URL. Keep the parameter and the cache filter in sync with
|
||||
* src/utils/chunkReload.ts and src/utils/serviceWorkerShell.ts.
|
||||
*
|
||||
* It must never throw.
|
||||
*/
|
||||
(function () {
|
||||
var PARAM = 'trek-reload';
|
||||
var MARKER = 'trek:shell-reload';
|
||||
var HEALTH_TIMEOUT_MS = 3000;
|
||||
|
||||
try {
|
||||
var here = new URL(window.location.href);
|
||||
if (here.searchParams.has(PARAM)) {
|
||||
here.searchParams.delete(PARAM);
|
||||
window.history.replaceState(window.history.state, '', here.pathname + here.search + here.hash);
|
||||
}
|
||||
} catch (e) {
|
||||
/* the parameter stays in the address bar, which is harmless */
|
||||
}
|
||||
|
||||
// The built entry lives under /assets/. The dev server's /src/main.tsx does
|
||||
// not, so a syntax error while developing is left to Vite's own overlay.
|
||||
function isEntry(el) {
|
||||
return !!el && el.tagName === 'SCRIPT' && el.type === 'module' && /\/assets\//.test(el.src || '');
|
||||
}
|
||||
|
||||
function alreadyTried() {
|
||||
try {
|
||||
return !!sessionStorage.getItem(MARKER);
|
||||
} catch (e) {
|
||||
// Without the marker there is no loop protection, so do nothing.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markTried() {
|
||||
try {
|
||||
sessionStorage.setItem(MARKER, String(Date.now()));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same test as probeNow() in src/sync/connectivity.ts: TREK's own health JSON,
|
||||
// not an edge proxy's login page and not a request that never came back.
|
||||
function serverAnswers() {
|
||||
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, HEALTH_TIMEOUT_MS) : null;
|
||||
return fetch('/api/health', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
redirect: 'manual',
|
||||
signal: ctrl ? ctrl.signal : undefined,
|
||||
}).then(function (res) {
|
||||
var type = res.headers.get('content-type') || '';
|
||||
return res.ok && type.indexOf('application/json') !== -1;
|
||||
}, function () {
|
||||
return false;
|
||||
}).then(function (ok) {
|
||||
if (timer) clearTimeout(timer);
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
|
||||
function servedByWorker() {
|
||||
try {
|
||||
return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dropAppShell() {
|
||||
var jobs = [];
|
||||
if (typeof caches !== 'undefined') {
|
||||
jobs.push(caches.keys().then(function (names) {
|
||||
return Promise.all(names.filter(function (name) {
|
||||
return name.indexOf('-precache-') !== -1;
|
||||
}).map(function (name) {
|
||||
return caches.delete(name);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
if (navigator.serviceWorker) {
|
||||
jobs.push(navigator.serviceWorker.getRegistration().then(function (reg) {
|
||||
return reg ? reg.unregister() : false;
|
||||
}));
|
||||
}
|
||||
return Promise.all(jobs);
|
||||
}
|
||||
|
||||
function reloadFresh() {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set(PARAM, Date.now().toString(36));
|
||||
window.location.replace(url.href);
|
||||
}
|
||||
|
||||
function recover() {
|
||||
if (alreadyTried()) return;
|
||||
// Read now: a page the worker did not serve failed on the network, and its
|
||||
// precache has nothing to do with it.
|
||||
var fromWorker = servedByWorker();
|
||||
serverAnswers().then(function (ok) {
|
||||
if (!ok || alreadyTried() || !markTried()) return;
|
||||
var dropped = fromWorker ? dropAppShell() : Promise.resolve();
|
||||
return dropped.then(null, function () {
|
||||
/* reload regardless, the server has the current build */
|
||||
}).then(reloadFresh);
|
||||
}).then(null, function () {
|
||||
/* never block boot */
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Error and load events of a script do not bubble, so both are caught on
|
||||
// their way down. On the document, since a load event never reaches window.
|
||||
document.addEventListener('error', function (event) {
|
||||
if (isEntry(event.target)) recover();
|
||||
}, true);
|
||||
// The shell started, so a later update in this session may recover too.
|
||||
document.addEventListener('load', function (event) {
|
||||
if (!isEntry(event.target)) return;
|
||||
try {
|
||||
sessionStorage.removeItem(MARKER);
|
||||
} catch (e) {
|
||||
/* nothing to clear */
|
||||
}
|
||||
}, true);
|
||||
} catch (e) {
|
||||
/* never block boot */
|
||||
}
|
||||
})();
|
||||
+27
-7
@@ -580,24 +580,44 @@ describe('Version cache-busting', () => {
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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.
|
||||
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.
|
||||
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: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
|
||||
value: { getRegistration, getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn() },
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
HttpResponse.json({ version: '2.9.10', places_provider: 'google' })
|
||||
)
|
||||
)
|
||||
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(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+6
-44
@@ -17,6 +17,7 @@ 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'
|
||||
@@ -284,7 +285,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -301,7 +302,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; 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; 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> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -309,6 +310,7 @@ 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)
|
||||
@@ -318,48 +320,8 @@ 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)
|
||||
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so it is rebuilt from the
|
||||
// characters a tag may contain and kept only when nothing had to be
|
||||
// stripped. 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 reportedVersion = typeof config?.version === 'string' ? config.version : ''
|
||||
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
|
||||
if (version && version === reportedVersion) {
|
||||
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
|
||||
}
|
||||
}
|
||||
// Last, since a new release reloads the page from here.
|
||||
await reconcileAppVersion(config?.version)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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-057
|
||||
// FE-APISURF-001 to FE-APISURF-059
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -455,6 +455,7 @@ describe('client > endpoint wiring', () => {
|
||||
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
|
||||
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
|
||||
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
|
||||
{ n: 'freezeRates', r: () => budgetApi.freezeRates(1, {}), e: 'POST /api/trips/1/budget/freeze-rates' },
|
||||
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
|
||||
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
|
||||
])
|
||||
@@ -572,6 +573,19 @@ describe('client > request payloads', () => {
|
||||
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-059: the settlement carries the display rate only when the caller has one', async () => {
|
||||
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', 0.61))).url).toBe('/api/trips/1/budget/settlement?base=EUR&base_rate=0.61')
|
||||
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', null))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
|
||||
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
|
||||
const lent = { fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }
|
||||
expect((await traceOne(() => budgetApi.freezeRates(1, lent))).body).toEqual(lent)
|
||||
})
|
||||
|
||||
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] })
|
||||
|
||||
+22
-10
@@ -1,7 +1,7 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import type { Day, Place, Trip } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
|
||||
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||
type BudgetCreateSettlementRequest, type BudgetUpdateSettlementRequest, type BudgetSettlementQuery,
|
||||
type BudgetFreezeRatesRequest, type BudgetFreezeRatesResponse,
|
||||
type TodoCategoryAssigneesRequest,
|
||||
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
||||
@@ -434,11 +436,17 @@ export const tripsApi = {
|
||||
|
||||
export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).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),
|
||||
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),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).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),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -1216,11 +1224,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 on an instance without a Google key.
|
||||
* 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.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
@@ -1346,9 +1354,13 @@ export const budgetApi = {
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
// `baseRate` is units of `base` per 1 trip currency from the client's own rates. The server
|
||||
// uses it only in place of a display quote it cannot fetch itself, which also reads legacy
|
||||
// transfers without a currency (in the display currency, as with a live quote).
|
||||
settlement: (tripId: number | string, base?: string, baseRate?: number | null) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base, ...(baseRate != null ? { base_rate: baseRate } : {}) } satisfies BudgetSettlementQuery } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: BudgetCreateSettlementRequest) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: BudgetUpdateSettlementRequest) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
freezeRates: (tripId: number | string, data: BudgetFreezeRatesRequest): Promise<BudgetFreezeRatesResponse> => apiClient.post(`/trips/${tripId}/budget/freeze-rates`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-W4BGT-001 to FE-W4BGT-022
|
||||
// FE-W4BGT-001 to FE-W4BGT-024
|
||||
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,3 +276,31 @@ 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, X } from 'lucide-react'
|
||||
import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
@@ -141,8 +141,12 @@ 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 && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||
{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)" />}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import CostsPanel, { ExpenseModal } from './CostsPanel'
|
||||
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
|
||||
import { resetFreezeAttempts } from './useFreezeMissingRates'
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
@@ -875,10 +876,11 @@ describe('CostsPanel — overview', () => {
|
||||
const alice = within(card).getByRole('button', { name: /You/ })
|
||||
await user.click(alice)
|
||||
expect(alice).toHaveAttribute('aria-expanded', 'true')
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row;
|
||||
// the raw 100 USD never shows up in the card.
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row; the
|
||||
// 100 USD is never converted again here. What was entered labels the row (#2525).
|
||||
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/100,00/)).toBeNull()
|
||||
expect(within(card).getByText('· $100.00')).toBeInTheDocument()
|
||||
// Received and still pending both lower her cost, each line with its one row.
|
||||
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
|
||||
@@ -2293,3 +2295,390 @@ describe('CostsPanel — expense modal in another language', () => {
|
||||
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2525: a bill entered in the display currency on a trip kept in another ───
|
||||
describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
// The reporter's setup: a euro trip, read in dollars, with the bill entered in
|
||||
// dollars. It was booked at 1.17 dollars to the euro, 685.26 EUR, and that is what
|
||||
// the balances and settle-up net. Today the euro buys 1.1551 dollars, so those
|
||||
// euros are $791.55. The dollar's own quote is rounded separately (0.865706 per
|
||||
// dollar is 1.155126 per euro), which is why the panel converts with the euro's.
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||||
clearExchangeRateCache()
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551, SEK: 10 }, ts: Date.now() }))
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
const hotel = (over: Partial<BudgetItem> = {}) => expense({
|
||||
id: 401, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||||
expense_date: '2026-08-06',
|
||||
payers: [{ user_id: 1, amount: 801.76 }],
|
||||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||||
...over,
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-079: a bill whose rate moved shows what was entered and what it was booked at', async () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
|
||||
// the 801.76 in the dialog. The pill beside the line is the last step.
|
||||
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
// Row total and payer chip stay the booked euros at today's rate, and so does what
|
||||
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
|
||||
// had Bob pay back more than he owed and reopened the trip.
|
||||
expect(screen.getAllByText('$791.55')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
|
||||
expect(screen.queryByText('you lent $400.88')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||||
mount([
|
||||
hotel({ id: 407, name: 'Villa', total_price: 12345.67, exchange_rate: 1.1551, payers: [{ user_id: 1, amount: 12345.67 }] }),
|
||||
hotel({ id: 408, name: 'Unpaid deposit', total_price: 250, exchange_rate: 1.1551, payers: [], members: [] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Villa')
|
||||
// Pill and payer chip; converting back with the dollar's own quote gave $12,346.05.
|
||||
expect(screen.getAllByText('$12,345.67')).toHaveLength(2)
|
||||
expect(screen.queryByText(/12,346\.0[56]/)).toBeNull()
|
||||
expect(screen.getByText('$12,595.67 spent')).toBeInTheDocument()
|
||||
const total = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(total.textContent).toContain('$12,595.67')
|
||||
const outstanding = screen.getByText('Outstanding amount').parentElement!.parentElement!.parentElement!
|
||||
expect(outstanding.textContent).toContain('$250.00')
|
||||
// Nothing to explain under either row.
|
||||
expect(screen.queryByText(/^\$12,345\.67 →/)).toBeNull()
|
||||
expect(screen.queryByText(/^\$250\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-088: the CSV export adds the trip-currency amount every sum is built from', async () => {
|
||||
let exported: Blob | null = null
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
try {
|
||||
mount([hotel()])
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
fireEvent.click(screen.getByTitle('Export CSV'))
|
||||
await waitFor(() => expect(exported).toBeTruthy())
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
|
||||
} finally {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-080: the totals add up the figures the rows show', async () => {
|
||||
mount([
|
||||
hotel(),
|
||||
hotel({ id: 402, name: 'Dinner', category: 'food', total_price: 120, payers: [{ user_id: 2, amount: 120 }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
|
||||
// $910.02, on the day header and on the total spend card.
|
||||
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
|
||||
// Label, its text block, the header row, then the card itself.
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(card.textContent).toContain('$910.02')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||||
// Nothing cached: the rates land after the expenses and the settlement, and the
|
||||
// totals used to keep the figure they were first added up with.
|
||||
clearExchangeRateCache()
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', async () => {
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
return HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
||||
}))
|
||||
mount([hotel({ id: 406, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||||
|
||||
// 100 euro at 0.8 euro to the dollar.
|
||||
expect(await screen.findByText(/100,00\s€ → \$125\.00/)).toBeInTheDocument()
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
await waitFor(() => expect(card.textContent).toContain('$125.00'))
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-081: a euro row converts live, a third currency shows the euros it was booked at', async () => {
|
||||
mount([
|
||||
hotel({ id: 403, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] }),
|
||||
hotel({ id: 404, name: 'Ferry', category: 'transport', total_price: 1000, currency: 'SEK', exchange_rate: 10, payers: [{ user_id: 1, amount: 1000 }] }),
|
||||
// Written before the freeze existed: nothing was booked, so dollars stay dollars.
|
||||
hotel({ id: 405, name: 'Old taxi', category: 'transport', total_price: 50, currency: 'USD', exchange_rate: 1, payers: [{ user_id: 1, amount: 50 }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Museum')
|
||||
// The trip currency has no frozen rate of its own and goes to dollars live.
|
||||
expect(screen.getByText(/^100,00\s€ → \$115\.51$/)).toBeInTheDocument()
|
||||
// 1000 SEK at 10 per euro is 100 euro, then live to dollars in the pill.
|
||||
expect(screen.getByText(/^1\s000,00\skr → 100,00\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('$50.00').length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
|
||||
mount([hotel()], {
|
||||
settlements: [
|
||||
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
|
||||
// Rows from before transfers had a currency were entered in the display
|
||||
// currency, which is how the server settles them.
|
||||
{ id: 22, from_user_id: 2, to_user_id: 1, amount: 30, currency: null, created_at: '2026-08-08 09:00:00' },
|
||||
],
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$395.77')).toBeInTheDocument()
|
||||
expect(screen.getByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 395.78 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -395.78 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 395.78 }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 791.55, reimbursed: 0, pending: 395.78, final: 395.77,
|
||||
sources: { fronted: [{ item_id: 401, cents: 79155 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 39578 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$791.55')).toHaveLength(2)
|
||||
expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument()
|
||||
expect(within(card).getByText('· $801.76')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel({ exchange_rate: 1.1551 })], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 400.88 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -400.88 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 400.88 }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 801.76, reimbursed: 0, pending: 400.88, final: 400.88,
|
||||
sources: { fronted: [{ item_id: 401, cents: 80176 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 40088 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$801.76')).toHaveLength(2)
|
||||
expect(within(card).queryByText('· $801.76')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => {
|
||||
const user = userEvent.setup()
|
||||
let put: Record<string, unknown> | null = null
|
||||
server.use(http.put('/api/trips/1/budget/405', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: hotel({ id: 405, currency: 'EUR' }) })
|
||||
}))
|
||||
mount([hotel({ id: 405, name: 'Tram pass', category: 'transport', total_price: 100, currency: null, exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||||
|
||||
await screen.findByText('Tram pass')
|
||||
// The list reads the missing currency as the trip's own.
|
||||
expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument()
|
||||
// Seeding the display currency here would label 100 euro as 100 dollars, and a
|
||||
// plain save would then store it that way.
|
||||
expect(screen.getByText(/^EUR/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^USD/)).toBeNull()
|
||||
|
||||
// Saving without touching anything keeps the 100 euro.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' }))
|
||||
})
|
||||
})
|
||||
|
||||
// ── A bill the server cannot convert: VND on an AUD trip ────────────────────────
|
||||
describe('CostsPanel: a bill the server cannot convert', () => {
|
||||
// The reporter's setup: an AUD trip whose server cannot fetch rates while the browser
|
||||
// can. An 8,920,000 VND dinner that never froze a rate was counted as 8,920,000 AUD.
|
||||
// The settlement now leaves it out and lists it, and an editor's browser lends its AUD
|
||||
// table to freeze a rate on it.
|
||||
const pho = () => expense({
|
||||
id: 501, name: 'Pho', category: 'food', total_price: 8920000, currency: 'VND', exchange_rate: 1,
|
||||
expense_date: '2026-09-20',
|
||||
payers: [{ user_id: 1, amount: 8920000 }],
|
||||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||||
})
|
||||
const leftOut = { balances: [], flows: [], settlements: [], currency: 'AUD', unconverted: { item_ids: [501], settlement_ids: [], currencies: ['VND'] } }
|
||||
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) })
|
||||
clearExchangeRateCache()
|
||||
resetFreezeAttempts()
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
it('FE-W5COSTS-090: VND/AUD regression: an editor heals the bill with the cached quote and the healed balances load', async () => {
|
||||
let healed = false
|
||||
const frozen: unknown[] = []
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [pho()] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json(healed
|
||||
? {
|
||||
// 8,920,000 VND at 18,241.3 is 489.00 AUD, half of it Bob's.
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 244.5 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -244.5 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 244.5 }],
|
||||
settlements: [],
|
||||
currency: 'AUD',
|
||||
unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||||
}
|
||||
: leftOut)),
|
||||
http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => {
|
||||
frozen.push(await request.json())
|
||||
healed = true
|
||||
return HttpResponse.json({ items: [{ ...pho(), exchange_rate: 18241.3 }], settlements: [], unresolved: [] })
|
||||
}),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// Only the currency the settlement is missing, at the rate the browser holds.
|
||||
await waitFor(() => expect(frozen).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }]))
|
||||
// The settlement is read again and Bob's half can be settled.
|
||||
expect(await screen.findByRole('button', { name: 'Settle' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/244\.50/).length).toBeGreaterThan(0)
|
||||
// Nothing on the page counts the bill as 8,920,000 dollars any more.
|
||||
expect(screen.queryByText(/\$8,920,000/)).toBeNull()
|
||||
expect(frozen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-091: a viewer\'s browser never freezes anything', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'admin' } })
|
||||
let frozen = 0
|
||||
server.use(http.post('/api/trips/1/budget/freeze-rates', () => {
|
||||
frozen += 1
|
||||
return HttpResponse.json({ items: [], settlements: [], unresolved: [] })
|
||||
}))
|
||||
mount([pho()], leftOut)
|
||||
|
||||
await screen.findByText('Pho')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(frozen).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-092: settle-up in the display currency lends its rate and asks the settlement in it', async () => {
|
||||
// Read in euros: the browser has the AUD table, the server has no quote at all.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
const asked: { base: string | null; baseRate: string | null }[] = []
|
||||
const posted: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
|
||||
return HttpResponse.json({
|
||||
balances: [], settlements: [], currency: 'EUR', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
|
||||
})
|
||||
}),
|
||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ settlement: { id: 1 } })
|
||||
}),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Settle' }))
|
||||
await waitFor(() => expect(posted).toHaveLength(1))
|
||||
// The transfer freezes at the rate the settlement was labelled with, so it cancels its
|
||||
// flow to the cent.
|
||||
expect(posted[0]).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||||
expect(asked.length).toBeGreaterThan(0)
|
||||
expect(asked.every(a => a.base === 'EUR' && a.baseRate === '0.61')).toBe(true)
|
||||
|
||||
const settleAll = screen.getByRole('button', { name: 'Settle up' })
|
||||
fireEvent.click(settleAll)
|
||||
await waitFor(() => expect(posted).toHaveLength(2))
|
||||
expect(posted[1]).toMatchObject({ amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-093: a payment added by hand lends the rate for its currency too', async () => {
|
||||
const user = userEvent.setup()
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1 } })
|
||||
}))
|
||||
mount([])
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted).toMatchObject({ amount: 25, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-094: a display rate the settlement query would refuse is not sent', async () => {
|
||||
// A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the query
|
||||
// takes, and sent as `base_rate` it would fail the whole settlement with a 400.
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XAU' }) })
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'IRR' } })
|
||||
localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() }))
|
||||
const asked: { base: string | null; baseRate: string | null }[] = []
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
|
||||
return HttpResponse.json({
|
||||
balances: [], flows: [], settlements: [], currency: 'XAU', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||||
})
|
||||
}),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await waitFor(() => expect(asked.length).toBeGreaterThan(0))
|
||||
// Still asked in rials, only without the figure; the server answers in the trip
|
||||
// currency when it has no quote of its own.
|
||||
expect(asked.every(a => a.base === 'IRR' && a.baseRate === null)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,9 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates'
|
||||
import { splitShareLabel, useExpenseFx } from './expenseFx'
|
||||
import { useFreezeMissingRates } from './useFreezeMissingRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
@@ -21,7 +23,7 @@ import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted } from '@trek/shared'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
@@ -59,6 +61,11 @@ interface SettlementData {
|
||||
// What the trip ends up costing each participant. Computed server-side off the
|
||||
// same ledger as the balances, so the breakdown can't contradict them.
|
||||
finalBudgets: BudgetParticipantFinal[]
|
||||
// The currency the figures are in: the display currency, or the trip's own when
|
||||
// neither the server nor `base_rate` could quote the pair.
|
||||
currency?: string
|
||||
// Rows no rate could convert, left out of every figure above.
|
||||
unconverted?: BudgetUnconverted
|
||||
}
|
||||
|
||||
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||
@@ -85,7 +92,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
|
||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
// Anchored on the trip currency's quote, the one the server books with (#2525).
|
||||
const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency)
|
||||
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
// A failed settlement read leaves `settlement` null, which the empty views would
|
||||
@@ -119,14 +127,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
||||
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
||||
|
||||
// The browser's own figure for the display currency goes along, for the server to
|
||||
// answer in it when it cannot fetch a quote itself.
|
||||
const loadSettlement = useCallback(() => {
|
||||
budgetApi.settlement(tripId, base)
|
||||
budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
|
||||
.then(s => { setSettlement(s); setSettlementError(false) })
|
||||
.catch(() => setSettlementError(true))
|
||||
}, [tripId, base])
|
||||
}, [tripId, base, tripCurrency, displayPerTrip])
|
||||
|
||||
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
|
||||
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
|
||||
useEffect(() => { loadSettlement() }, [budgetItems.length, loadSettlement])
|
||||
|
||||
// Rows the server could not count get a rate frozen from the browser's, and the
|
||||
// settlement is read again once they count.
|
||||
useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement })
|
||||
|
||||
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -145,12 +159,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
[convert, tripCurrency],
|
||||
)
|
||||
const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e)
|
||||
// A transfer freezes its own rate at settle time, in its own table (#1445).
|
||||
// A transfer freezes its own rate at settle time, in its own table (#1445). One
|
||||
// without a currency predates that and was entered in the display currency, which
|
||||
// is how the server settles it too, not in the trip's.
|
||||
const settled = useCallback(
|
||||
(s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert),
|
||||
[convert, tripCurrency],
|
||||
(s: Settlement) => convertBooked(s.amount, s.currency || base, s.exchange_rate, tripCurrency, convert),
|
||||
[convert, tripCurrency, base],
|
||||
)
|
||||
const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e)
|
||||
// The line under an amount shown converted: what was entered, then where it went (#2525).
|
||||
const lineOf = (amount: number, e: BudgetItem, shown: number) =>
|
||||
convertedLine(amount, e.currency, e.exchange_rate, tripCurrency, base, shown)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
@@ -169,6 +188,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return booked(myShare, e)
|
||||
}
|
||||
|
||||
// `booked` carries the rates. They can land after the expenses, and without it in the
|
||||
// deps the cards kept the sums they were first added up with while the rows moved on.
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
|
||||
@@ -178,7 +199,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
|
||||
const outstandingCount = budgetItems.filter(isUnfinished).length
|
||||
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
|
||||
}, [budgetItems, settlement, me])
|
||||
}, [budgetItems, settlement, me, booked])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
@@ -192,7 +213,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
||||
return list
|
||||
}, [budgetItems, filter, search, catFilter, dayFilter, me])
|
||||
}, [budgetItems, filter, search, catFilter, dayFilter, me, booked])
|
||||
|
||||
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||
@@ -245,7 +266,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
try {
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base })
|
||||
await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency))
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
@@ -256,7 +277,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const flows = settlement?.flows || []
|
||||
if (!flows.length) return
|
||||
try {
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, tripCurrency))
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
// Refresh even when one transfer failed: the ones created before it are real,
|
||||
// and leaving them in the flow list invites a second, doubled settle-up.
|
||||
@@ -290,15 +311,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
|
||||
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
|
||||
// Read in another currency than the trip's, what each row counts as in the trip
|
||||
// currency too, the figure every sum is built from (#2525).
|
||||
const tripCol = tripCurrency !== base
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? ['Amount (' + tripCurrency + ')'] : []), 'Amount (' + base + ')', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of items) {
|
||||
const cur = curOf(e)
|
||||
const note = readUserNote(e)
|
||||
const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, tripCurrency, convert)
|
||||
rows.push([
|
||||
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
|
||||
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
|
||||
...(tripCol ? [inTrip.toFixed(currencyDecimals(tripCurrency))] : []),
|
||||
baseTotal(e).toFixed(currencyDecimals(base)),
|
||||
esc(note),
|
||||
].join(sep))
|
||||
@@ -521,7 +547,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)}
|
||||
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base} tripCurrency={tripCurrency}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
@@ -768,7 +794,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
const cur = curOf(e)
|
||||
const line = lineOf(e.total_price || 0, e, baseTotal(e))
|
||||
const payers = (e.payers || []).filter(p => p.amount !== 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
@@ -837,9 +863,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
{line && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))}
|
||||
{fmt(line.entered.amount, line.entered.currency)} {'→'} {fmt(line.into.amount, line.into.currency)}
|
||||
</div>
|
||||
)}
|
||||
{/* Under the name, because a chip reading "Y 122,00" says nothing on
|
||||
@@ -894,6 +920,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
function SettlementRow({ s }: { s: Settlement }) {
|
||||
// Legacy transfers carry no currency and were entered in the display base.
|
||||
const cur = (s.currency || base).toUpperCase()
|
||||
// Booked in the trip currency like an expense, so it is explained the same way.
|
||||
const line = convertedLine(s.amount, cur, s.exchange_rate, tripCurrency, base, settled(s))
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
@@ -901,7 +929,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
|
||||
{t('costs.payment')}
|
||||
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(settled(s))}</span>}
|
||||
{line && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
@@ -1000,6 +1028,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// server's display cents, not a conversion of the expense list done here.
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
|
||||
// Beside a converted row's name, what was entered, so the breakdown names the bill
|
||||
// the way the list above it does rather than by a figure nobody typed.
|
||||
const enteredOf = (itemId: number, shown: number) => {
|
||||
const e = budgetItems.find(i => i.id === itemId)
|
||||
const line = e ? lineOf(paidByUser(e, row.user_id), e, shown) : null
|
||||
return line ? fmt(line.entered.amount, line.entered.currency) : null
|
||||
}
|
||||
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
|
||||
const detail = (label: string, value: string) => (
|
||||
<div style={lineCls}>
|
||||
@@ -1022,12 +1057,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalExpenses')}</div>
|
||||
<div style={listCls}>
|
||||
{fronted.map(r => (
|
||||
<div key={r.item_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
{fronted.map(r => {
|
||||
const entered = enteredOf(r.item_id, r.amount)
|
||||
return (
|
||||
<div key={r.item_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.name}{entered && <span className="text-content-faint"> · {entered}</span>}
|
||||
</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1145,8 +1185,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
// A transfer can be made in any currency — paying a rouble debt in euros is normal —
|
||||
// so it carries its own, defaulting to the display currency. The server freezes its
|
||||
// FX rate on write, the same way an expense's is frozen.
|
||||
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
|
||||
function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; tripCurrency: string; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -1167,7 +1207,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
|
||||
const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency)
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
@@ -1237,12 +1277,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
// A saved expense without a currency opens in the trip's own (#2525).
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [currency, setCurrency] = useState(editingCurrency)
|
||||
const [day, setDay] = useState(editing?.expense_date || localToday())
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
@@ -1250,7 +1291,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
|
||||
// which is also what the currency field starts on.
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
|
||||
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
|
||||
return ''
|
||||
})
|
||||
@@ -1334,6 +1375,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}, [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const fx = preview(totalNum, currency)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
@@ -1591,12 +1633,18 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && totalNum !== 0 && (
|
||||
{fx && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
{fx.inTrip != null && <>
|
||||
<span className="text-content-faint">→</span>
|
||||
<span className={fx.shown == null ? 'text-content' : undefined} style={fx.shown == null ? { fontWeight: 600 } : undefined}>{formatMoney(fx.inTrip, tripCur, locale)}</span>
|
||||
</>}
|
||||
{fx.shown != null && <>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(fx.shown, base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1823,7 +1871,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
{splitMode === 'equally' ? (
|
||||
<span className="text-content-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { editingCurrencyOf, expenseFxPreview, splitShareLabel } from './expenseFx'
|
||||
|
||||
// A euro trip read in dollars, converted with the euro's own quote: one euro is 1.1551
|
||||
// dollars today, and 1.17 on the day the hotel was booked (#2525).
|
||||
const convert = (amount: number, from: string | null | undefined): number =>
|
||||
(amount / ({ EUR: 1, USD: 1.1551, JPY: 170 } as Record<string, number>)[(from || 'USD').toUpperCase()]) * 1.1551
|
||||
const hotel = { currency: 'USD', exchange_rate: 1.17 }
|
||||
const base = { editing: hotel, editingCurrency: 'USD', tripCurrency: 'EUR', base: 'USD', convert }
|
||||
|
||||
describe('editingCurrencyOf', () => {
|
||||
it('opens a saved expense without a currency in the trip currency', () => {
|
||||
expect(editingCurrencyOf({ currency: null }, 'eur', 'USD')).toBe('EUR')
|
||||
expect(editingCurrencyOf({ currency: 'jpy' }, 'EUR', 'USD')).toBe('JPY')
|
||||
})
|
||||
|
||||
it('starts a new expense, or one on a trip without a currency, in the display currency', () => {
|
||||
expect(editingCurrencyOf(null, 'EUR', 'usd')).toBe('USD')
|
||||
expect(editingCurrencyOf({ currency: null }, null, 'usd')).toBe('USD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expenseFxPreview', () => {
|
||||
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
|
||||
expect(fx.inTrip).toBeCloseTo(685.26, 2)
|
||||
expect(fx.shown).toBeCloseTo(791.55, 2)
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
|
||||
expect(expenseFxPreview({ ...base, editing: { currency: 'USD', exchange_rate: 1.1551 }, total: 12345.67, currency: 'USD' })).toBeNull()
|
||||
expect(expenseFxPreview({ ...base, editing: null, total: 250, currency: 'USD' })).toBeNull()
|
||||
expect(expenseFxPreview({ ...base, total: 0, currency: 'USD' })).toBeNull()
|
||||
})
|
||||
|
||||
it('previews a new or re-currencied amount at today\'s rate', () => {
|
||||
expect(expenseFxPreview({ ...base, editing: null, total: 1700, currency: 'JPY' })).toEqual({ inTrip: null, shown: convert(1700, 'JPY') })
|
||||
// Switching the hotel to yen re-freezes on save, so the old rate does not apply.
|
||||
expect(expenseFxPreview({ ...base, total: 1700, currency: 'JPY' })?.inTrip).toBeNull()
|
||||
})
|
||||
|
||||
it('stops at the trip amount when the viewer reads the trip currency', () => {
|
||||
expect(expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })).toEqual({ inTrip: 801.76 / 1.17, shown: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitShareLabel', () => {
|
||||
const sym = (c: string) => (c === 'USD' ? '$' : c + ' ')
|
||||
|
||||
it('prints the entered share, then the share the row lends or borrows', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77')
|
||||
// Read in the trip currency, the share is the booked euros.
|
||||
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
})
|
||||
|
||||
it('prints the entered share alone when nothing is converted', () => {
|
||||
expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00')
|
||||
expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { currencyDecimals, formatMoney } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
|
||||
* phone MCostSheet so the two can never preview the same save differently (#2525). The
|
||||
* editors only lay it out.
|
||||
*/
|
||||
|
||||
interface EditedExpense {
|
||||
currency?: string | null
|
||||
exchange_rate?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The currency an edited expense opens in. A saved expense without a currency is in the
|
||||
* trip's own, which is how the lists read it; opening it in the display currency relabelled
|
||||
* the amount, and a save then stored it in that currency.
|
||||
*/
|
||||
export function editingCurrencyOf(editing: EditedExpense | null | undefined, tripCurrency: string | null | undefined, base: string): string {
|
||||
return ((editing && (editing.currency || tripCurrency)) || base).toUpperCase()
|
||||
}
|
||||
|
||||
export interface ExpenseFx {
|
||||
/** The amount in the trip currency, when the save keeps a rate frozen on entry. */
|
||||
inTrip: number | null
|
||||
/** The amount in the display currency at today's rate; null when `inTrip` already is. */
|
||||
shown: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
|
||||
* frozen when the expense was entered (the server re-freezes only when the currency changes),
|
||||
* so it is previewed at that rate, the way the list will show it after the save. Anything
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
|
||||
* in the display currency that still reads back as typed.
|
||||
*/
|
||||
export function expenseFxPreview(args: {
|
||||
total: number
|
||||
currency: string
|
||||
editing: EditedExpense | null | undefined
|
||||
editingCurrency: string
|
||||
tripCurrency: string
|
||||
base: string
|
||||
convert: (amount: number, from: string | null | undefined) => number
|
||||
}): ExpenseFx | null {
|
||||
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
|
||||
if (total === 0) return null
|
||||
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
|
||||
if (currency === base && inTrip == null) return null
|
||||
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
|
||||
const unit = 10 ** currencyDecimals(base)
|
||||
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
|
||||
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
|
||||
}
|
||||
|
||||
/**
|
||||
* One equal share as the split summary prints it, "$400.88", followed by what that share
|
||||
* counts as when the amount is converted, "$400.88 → $390.54". That second figure is the
|
||||
* one the row's "you lent" is made of, so the editor and the row cannot disagree.
|
||||
*/
|
||||
export function splitShareLabel(
|
||||
each: number,
|
||||
currency: string,
|
||||
fx: ExpenseFx | null,
|
||||
participants: number,
|
||||
base: string,
|
||||
sym: (currency: string) => string,
|
||||
locale: string,
|
||||
): string {
|
||||
const entered = sym(currency) + each.toFixed(2)
|
||||
const whole = fx ? (fx.shown ?? fx.inTrip) : null
|
||||
if (whole == null || participants <= 0) return entered
|
||||
return `${entered} → ${formatMoney(whole / participants, base, locale)}`
|
||||
}
|
||||
|
||||
/** Everything an expense editor needs to preview a conversion, from the trip in the store. */
|
||||
export function useExpenseFx(base: string, editing: EditedExpense | null | undefined) {
|
||||
const storedTripCurrency = useTripStore(s => s.trip?.currency)
|
||||
const tripCurrency = (storedTripCurrency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base, tripCurrency)
|
||||
const editingCurrency = editingCurrencyOf(editing, storedTripCurrency, base)
|
||||
const preview = (total: number, currency: string) =>
|
||||
expenseFxPreview({ total, currency, editing, editingCurrency, tripCurrency, base, convert })
|
||||
return { tripCurrency, editingCurrency, preview }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// FE-FREEZE-RATES-001 to FE-FREEZE-RATES-006
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import type { BudgetUnconverted } from '@trek/shared'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
|
||||
import { resetFreezeAttempts, useFreezeMissingRates } from './useFreezeMissingRates'
|
||||
|
||||
// The reporter's setup: an AUD trip whose server cannot fetch rates, and a VND bill that
|
||||
// never froze one. The browser has today's AUD table.
|
||||
const bill = () => buildBudgetItem({ id: 11, trip_id: 5, name: 'Pho', total_price: 8920000, currency: 'VND', exchange_rate: 1 })
|
||||
const pending: BudgetUnconverted = { item_ids: [11], settlement_ids: [3], currencies: ['VND'] }
|
||||
|
||||
type Props = Parameters<typeof useFreezeMissingRates>[0]
|
||||
|
||||
function mountHook(over: Partial<Props> = {}) {
|
||||
const onHealed = vi.fn()
|
||||
const props: Props = { tripId: 5, tripCurrency: 'AUD', canEdit: true, unconverted: pending, onHealed, ...over }
|
||||
const view = renderHook((p: Props) => useFreezeMissingRates(p), { initialProps: props })
|
||||
return { ...view, onHealed, props }
|
||||
}
|
||||
|
||||
let posted: unknown[] = []
|
||||
|
||||
function serveFreeze(reply: () => Response = () => HttpResponse.json({
|
||||
items: [{ ...bill(), exchange_rate: 18241.3 }],
|
||||
settlements: [{ id: 3, trip_id: 5, from_user_id: 2, to_user_id: 1, amount: 100000, currency: 'VND', exchange_rate: 18241.3 }],
|
||||
unresolved: [],
|
||||
})) {
|
||||
server.use(http.post('/api/trips/5/budget/freeze-rates', async ({ request }) => {
|
||||
posted.push(await request.json())
|
||||
return reply()
|
||||
}))
|
||||
}
|
||||
|
||||
/** Lets a heal that was never going to send anything run to its end. */
|
||||
const settle = () => act(async () => { await new Promise(r => setTimeout(r, 30)) })
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
resetFreezeAttempts()
|
||||
clearExchangeRateCache()
|
||||
posted = []
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
|
||||
seedStore(useTripStore, { budgetItems: [bill()] })
|
||||
serveFreeze()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setForcedOffline(false)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useFreezeMissingRates', () => {
|
||||
it('FE-FREEZE-RATES-001: lends the fresh rates for the listed currencies and reloads once rows count', async () => {
|
||||
const { onHealed } = mountHook()
|
||||
|
||||
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
|
||||
// Only the currency the settlement is missing goes out, never the whole table.
|
||||
expect(posted).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }])
|
||||
expect(useTripStore.getState().budgetItems[0].exchange_rate).toBe(18241.3)
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-002: asks once per set of rows, however often the settlement is read', async () => {
|
||||
const { onHealed, props, rerender } = mountHook()
|
||||
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The same rows in another order, then again after a reload: nothing new to ask.
|
||||
rerender({ ...props, unconverted: { item_ids: [11], settlement_ids: [3], currencies: ['VND'] } })
|
||||
rerender({ ...props, unconverted: { settlement_ids: [3], item_ids: [11], currencies: ['VND'] } })
|
||||
await settle()
|
||||
expect(posted).toHaveLength(1)
|
||||
|
||||
// Another row is another attempt.
|
||||
rerender({ ...props, unconverted: { item_ids: [11, 12], settlement_ids: [3], currencies: ['VND'] } })
|
||||
await waitFor(() => expect(posted).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-003: never asks for a viewer, offline, or with nothing left out', async () => {
|
||||
mountHook({ canEdit: false })
|
||||
mountHook({ unconverted: { item_ids: [], settlement_ids: [], currencies: [] } })
|
||||
mountHook({ unconverted: undefined })
|
||||
setForcedOffline(true)
|
||||
mountHook({ unconverted: { item_ids: [21], settlement_ids: [], currencies: ['VND'] } })
|
||||
await settle()
|
||||
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-004: sends nothing when the browser has no rate for the currency either', async () => {
|
||||
const { onHealed } = mountHook({ unconverted: { item_ids: [31], settlement_ids: [], currencies: ['JPY'] } })
|
||||
await settle()
|
||||
|
||||
expect(posted).toEqual([])
|
||||
expect(onHealed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-005: does not reload when the server froze nothing', async () => {
|
||||
serveFreeze(() => HttpResponse.json({ items: [], settlements: [], unresolved: ['VND'] }))
|
||||
const { onHealed } = mountHook()
|
||||
|
||||
await waitFor(() => expect(posted).toHaveLength(1))
|
||||
await settle()
|
||||
expect(onHealed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-006: a refused heal is logged, not thrown, and not retried in a loop', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
serveFreeze(() => HttpResponse.json({ error: 'The trip currency changed. Reload and try again.' }, { status: 409 }))
|
||||
const { onHealed, props, rerender } = mountHook()
|
||||
|
||||
await waitFor(() => expect(warn).toHaveBeenCalled())
|
||||
rerender({ ...props })
|
||||
await settle()
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(onHealed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { BudgetFallbackFx, BudgetUnconverted } from '@trek/shared'
|
||||
import { fetchFreshRates, isSendableRate } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
|
||||
/**
|
||||
* Every set of rows already tried, by trip, trip currency and ids. The settlement lists a
|
||||
* row it cannot count until a rate is frozen on it; one the browser has no rate for either
|
||||
* stays listed, and without this every reload of the settlement would ask again.
|
||||
*/
|
||||
const tried = new Set<string>()
|
||||
|
||||
/** Test-only: the attempts outlive a vitest file's individual tests. */
|
||||
export function resetFreezeAttempts(): void {
|
||||
tried.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals the rows the settlement leaves out (`unconverted`): expenses and transfers in a
|
||||
* foreign currency that never froze a rate, while the server cannot fetch one. That is the
|
||||
* setup where the server's own fetch fails and the browser's works, so an editor's browser
|
||||
* lends its fresh rates to POST …/budget/freeze-rates. The server still prefers its own rate
|
||||
* and never touches a row that is frozen already.
|
||||
*
|
||||
* Runs for an editor only, online, and once per set of rows. No request goes out when the
|
||||
* browser has no rate for any of the listed currencies either. `onHealed` runs when the
|
||||
* server froze something, so the caller can reload the settlement.
|
||||
*
|
||||
* Deliberately not in the mutation queue: the heal is derived and idempotent, and a replay
|
||||
* hours later would freeze a rate from another day.
|
||||
*/
|
||||
export function useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted, onHealed }: {
|
||||
tripId: number
|
||||
tripCurrency: string
|
||||
canEdit: boolean
|
||||
unconverted: BudgetUnconverted | null | undefined
|
||||
onHealed: () => void
|
||||
}): void {
|
||||
const onHealedRef = useRef(onHealed)
|
||||
useEffect(() => { onHealedRef.current = onHealed })
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => { mounted.current = false }
|
||||
}, [])
|
||||
|
||||
const trip = (tripCurrency || '').toUpperCase()
|
||||
const itemIds = [...(unconverted?.item_ids ?? [])].sort((a, b) => a - b).join(',')
|
||||
const settlementIds = [...(unconverted?.settlement_ids ?? [])].sort((a, b) => a - b).join(',')
|
||||
const currencies = (unconverted?.currencies ?? []).join(',')
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit || !trip || (!itemIds && !settlementIds) || isEffectivelyOffline()) return
|
||||
const key = `${tripId}:${trip}:${itemIds}:${settlementIds}`
|
||||
if (tried.has(key)) return
|
||||
tried.add(key)
|
||||
|
||||
const heal = async (): Promise<void> => {
|
||||
const rates = await fetchFreshRates(trip)
|
||||
const lent: Record<string, number> = {}
|
||||
for (const cur of currencies.split(',')) {
|
||||
const rate = rates?.[cur]
|
||||
if (isSendableRate(rate)) lent[cur] = rate
|
||||
}
|
||||
if (Object.keys(lent).length === 0) return
|
||||
const fallback: BudgetFallbackFx = { base: trip, rates: lent }
|
||||
const result = await useTripStore.getState().freezeMissingRates(tripId, fallback)
|
||||
if (mounted.current && (result.items.length > 0 || result.settlements.length > 0)) onHealedRef.current()
|
||||
}
|
||||
heal().catch((err: unknown) => console.warn('Could not freeze the missing exchange rates:', err))
|
||||
}, [canEdit, trip, tripId, itemIds, settlementIds, currencies])
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
|
||||
// FE-COMP-LINKS-001 to FE-COMP-LINKS-013
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -160,6 +162,67 @@ 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,6 +6,7 @@ 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'
|
||||
|
||||
@@ -111,6 +112,7 @@ 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.
|
||||
@@ -230,7 +232,9 @@ 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')}>
|
||||
<LinkIcon url={link.url} />
|
||||
{/* 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} />
|
||||
<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>
|
||||
@@ -244,7 +248,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={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
|
||||
<button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
@@ -258,6 +262,16 @@ 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,7 +201,9 @@ 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();
|
||||
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
|
||||
// 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 waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-022
|
||||
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-023
|
||||
|
||||
/**
|
||||
* The document-sync dialog shell (#2391).
|
||||
@@ -49,6 +49,27 @@ 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'
|
||||
|
||||
@@ -142,6 +163,7 @@ const confirmUnlink = () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
slowCard = false
|
||||
serverHas({})
|
||||
deleteLink.mockResolvedValue({})
|
||||
updateLink.mockResolvedValue({})
|
||||
@@ -301,6 +323,39 @@ 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,11 +54,12 @@ 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.
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (sync.links.length === 0) setSelected(null)
|
||||
else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id)
|
||||
}, [sync.links, selected])
|
||||
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
|
||||
}, [sync.links])
|
||||
|
||||
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-040
|
||||
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -1045,4 +1045,30 @@ 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 } from 'react'
|
||||
import { useEffect, useMemo, useState, useRef, type SyntheticEvent } 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,6 +21,27 @@ 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
|
||||
@@ -434,7 +455,11 @@ 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%' }}
|
||||
>
|
||||
<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 }} />
|
||||
{posterlessVideo(gp) ? (
|
||||
<ClipTile />
|
||||
) : (
|
||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{availableGalleryPhotos.length === 0 && (
|
||||
@@ -528,13 +553,9 @@ 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) ? (
|
||||
// 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>
|
||||
<ClipTile />
|
||||
) : (
|
||||
<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 }} />
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
|
||||
)}
|
||||
{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-008
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-010
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -112,4 +112,23 @@ 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,6 +1,7 @@
|
||||
import { MapPin, Plus } from 'lucide-react'
|
||||
import { MapPin, Play, 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'
|
||||
@@ -25,6 +26,13 @@ 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
|
||||
@@ -39,7 +47,7 @@ export interface CoverEntry {
|
||||
entry_time?: string | null
|
||||
mood?: string | null
|
||||
weather?: string | null
|
||||
photos?: CardPhoto[]
|
||||
photos?: CoverPhoto[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -69,8 +77,12 @@ export default function JourneyEntryCover({
|
||||
const { t, locale } = useTranslation()
|
||||
const isSuggestion = entry.type === 'skeleton'
|
||||
|
||||
const photoId = cardPhotoId(entry.photos?.[0])
|
||||
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
|
||||
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 title = cardTitle(entry, t)
|
||||
const place = cardPlace(entry)
|
||||
@@ -112,7 +124,9 @@ 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">
|
||||
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
|
||||
{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" />}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -234,4 +234,25 @@ 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,6 +7,7 @@ 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
|
||||
@@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
}`}
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{/* 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'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{photos[0].media_type === 'video' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
}`}
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
/>
|
||||
{!posterlessVideo(p) && (
|
||||
<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,10 +3,18 @@ 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', () => ({}))
|
||||
@@ -31,7 +39,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 unmounted in the same tick.
|
||||
// same, and the element is out of the document by then.
|
||||
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
|
||||
|
||||
const [, options] = plyrCtor.mock.calls[0]
|
||||
@@ -45,4 +53,19 @@ 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,24 +7,30 @@ 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({
|
||||
src,
|
||||
poster,
|
||||
autoPlay = true,
|
||||
style,
|
||||
}: {
|
||||
src: string
|
||||
poster?: string
|
||||
autoPlay?: boolean
|
||||
style?: React.CSSProperties
|
||||
}): React.ReactElement {
|
||||
export default function VideoPlayer(props: Props): React.ReactElement {
|
||||
return <PlyrVideo key={props.src} {...props} />
|
||||
}
|
||||
|
||||
function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,8 +54,8 @@ export default function VideoPlayer({
|
||||
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 unmounted in the same
|
||||
// tick, so the error it would otherwise report has nowhere to show.
|
||||
// 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.
|
||||
blankVideo: '',
|
||||
})
|
||||
|
||||
@@ -60,7 +66,7 @@ export default function VideoPlayer({
|
||||
/* already torn down */
|
||||
}
|
||||
}
|
||||
}, [src, autoPlay])
|
||||
}, [autoPlay])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Clicking a place pin after the map has been dragged (#2504).
|
||||
*
|
||||
* Nothing Leaflet-related is mocked here on purpose. The bug lived in the space between
|
||||
* our drag wiring and Leaflet's own event routing: the pins swallow mousedown so a drag
|
||||
* onto a day can start without the map sliding away, and Leaflet only forgets that the
|
||||
* map was panned when it sees the next mousedown. A mocked react-leaflet, like the one
|
||||
* MapView.test.tsx uses, has no such memory and kept passing throughout.
|
||||
*
|
||||
* The click itself has to keep taking Leaflet's route and the page's: it is what closes
|
||||
* an open popup on the map, and an open context menu beside it.
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { render, waitFor, act } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
import L from 'leaflet'
|
||||
import { MapView } from './MapView'
|
||||
import { ContextMenu } from '../shared/ContextMenu'
|
||||
|
||||
// Every map this file builds, newest last, for the tests that reach past the component.
|
||||
const maps: L.Map[] = []
|
||||
L.Map.addInitHook(function (this: L.Map) { maps.push(this) })
|
||||
|
||||
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
|
||||
const RASTER = 'https://tiles.example.test/{z}/{x}/{y}.png'
|
||||
|
||||
// Leaflet sizes the map from the container, which jsdom lays out at 0 x 0. The cluster
|
||||
// group only draws pins inside the visible bounds, so the map needs a real size.
|
||||
const sized = ['clientWidth', 'clientHeight'] as const
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>()
|
||||
beforeAll(() => {
|
||||
// The pans here are synthetic and instant. With inertia, Leaflet would carry them on in
|
||||
// animation frames that can land after a test has already unmounted its map.
|
||||
L.Map.mergeOptions({ inertia: false })
|
||||
for (const key of sized) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key))
|
||||
Object.defineProperty(HTMLElement.prototype, key, { configurable: true, get: () => (key === 'clientWidth' ? 1000 : 700) })
|
||||
}
|
||||
})
|
||||
afterAll(() => {
|
||||
L.Map.mergeOptions({ inertia: true })
|
||||
for (const key of sized) {
|
||||
const original = originals.get(key)
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, key, original)
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
/** Leaflet only starts a pan for the primary button, which it reads off `which`. */
|
||||
function mouse(type: string, x: number, y: number): MouseEvent {
|
||||
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: y })
|
||||
Object.defineProperty(event, 'which', { value: 1 })
|
||||
return event
|
||||
}
|
||||
|
||||
/** Drag the map by its background, the way a user pans it. */
|
||||
function panMap(container: Element) {
|
||||
act(() => {
|
||||
container.dispatchEvent(mouse('mousedown', 300, 300))
|
||||
container.dispatchEvent(mouse('mousemove', 340, 320))
|
||||
container.dispatchEvent(mouse('mousemove', 380, 340))
|
||||
container.dispatchEvent(mouse('mouseup', 380, 340))
|
||||
})
|
||||
}
|
||||
|
||||
/** A plain click on an element: press, release, click, all on the same spot. */
|
||||
function clickOn(el: Element) {
|
||||
act(() => {
|
||||
el.dispatchEvent(mouse('mousedown', 500, 350))
|
||||
el.dispatchEvent(mouse('mouseup', 500, 350))
|
||||
el.dispatchEvent(mouse('click', 500, 350))
|
||||
})
|
||||
}
|
||||
|
||||
// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster.
|
||||
const places = [
|
||||
{ ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null },
|
||||
{ ...buildPlace({ id: 2, name: 'Kasuga Taisha', lat: 34.6812, lng: 135.8484 }), category_name: null, category_color: null, category_icon: null },
|
||||
]
|
||||
|
||||
async function renderMap() {
|
||||
const onMarkerClick = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} onMapClick={onMapClick} />,
|
||||
)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
// The cluster group takes its markers in on a timer (chunkedLoading).
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
|
||||
const pins = [...container.querySelectorAll('.leaflet-marker-icon[draggable="true"]')] as HTMLElement[]
|
||||
return { container, pins, onMarkerClick, onMapClick }
|
||||
}
|
||||
|
||||
describe('MapView on a real Leaflet map', () => {
|
||||
it('FE-MAPCLICK-001: a pin opens its place on a map nobody has dragged yet', async () => {
|
||||
const { pins, onMarkerClick, onMapClick } = await renderMap()
|
||||
clickOn(pins[0])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
// Still the marker's click, not one on the empty map behind it.
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-002: a pin still opens its place after the map was dragged (#2504)', async () => {
|
||||
const { container, pins, onMarkerClick, onMapClick } = await renderMap()
|
||||
panMap(container)
|
||||
clickOn(pins[0])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
// And again: the report was that every click after the pan went nowhere.
|
||||
clickOn(pins[1])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(2)
|
||||
expect(onMarkerClick.mock.calls.map(call => call[0]).sort()).toEqual([1, 2])
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-003: the click that ends a pan still counts for nothing', async () => {
|
||||
const { container, onMarkerClick, onMapClick } = await renderMap()
|
||||
panMap(container)
|
||||
// The browser aims the click that closes a drag at the element both ends share,
|
||||
// which is the map rather than the pin the pointer happened to stop on. Leaflet
|
||||
// swallows it, and must go on doing so: it is not a click on the empty map either.
|
||||
act(() => { container.dispatchEvent(mouse('click', 380, 340)) })
|
||||
expect(onMarkerClick).not.toHaveBeenCalled()
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("FE-MAPCLICK-004: a pin on a touch screen keeps Leaflet's own click and no drag wiring", async () => {
|
||||
const original = Object.getOwnPropertyDescriptor(window.navigator, 'maxTouchPoints')
|
||||
Object.defineProperty(window.navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||
try {
|
||||
const onMarkerClick = vi.fn()
|
||||
const view = render(<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} />)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon').length).toBe(2))
|
||||
const pin = container.querySelector('.leaflet-marker-icon') as HTMLElement
|
||||
expect(pin.getAttribute('draggable')).toBeNull()
|
||||
clickOn(pin)
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
if (original) Object.defineProperty(window.navigator, 'maxTouchPoints', original)
|
||||
else delete (window.navigator as unknown as Record<string, unknown>).maxTouchPoints
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-005: the place a pin opens stays open when the pin redraws mid-click', async () => {
|
||||
// The selection a pin makes redraws its icon, which replaces the element the click
|
||||
// landed on. Leaflet has to have found the pin before that happens: reading the target
|
||||
// afterwards, it finds it cut out of the map and takes the click for one on the empty
|
||||
// background, which would close the place again straight away.
|
||||
const onMapClick = vi.fn()
|
||||
function Planner() {
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
return (
|
||||
<>
|
||||
<output data-testid="selected">{selected ?? ''}</output>
|
||||
<MapView
|
||||
places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER}
|
||||
selectedPlaceId={selected}
|
||||
onMarkerClick={(id: number) => flushSync(() => setSelected(id))}
|
||||
onMapClick={() => { onMapClick(); setSelected(null) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const view = render(<Planner />)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
|
||||
const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement
|
||||
// The pointer lands on the photo inside the pin, which the redraw replaces.
|
||||
clickOn(pin.firstElementChild as HTMLElement)
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
expect(view.getByTestId('selected').textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-006: a pin click after a pan still closes an open popup on the map', async () => {
|
||||
const { container, pins, onMarkerClick } = await renderMap()
|
||||
const map = maps[maps.length - 1]
|
||||
panMap(container)
|
||||
// A hazard or plugin popup, as the planner opens them. Leaflet closes those on the
|
||||
// "preclick" it sends ahead of every click it routes, a pin's included.
|
||||
const popup = L.popup({ autoPan: false }).setLatLng([34.685, 135.844]).setContent('Road closed')
|
||||
act(() => { popup.openOn(map) })
|
||||
expect(map.hasLayer(popup)).toBe(true)
|
||||
clickOn(pins[0])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
expect(map.hasLayer(popup)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-007: a pin click after a pan still reaches the page and closes an open context menu', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onMarkerClick = vi.fn()
|
||||
const view = render(
|
||||
<>
|
||||
{/* The menu the places list and the day plan open on a right click. It closes on
|
||||
any click that reaches the document. */}
|
||||
<ContextMenu menu={{ x: 20, y: 20, items: [{ label: 'Edit' }] }} onClose={onClose} />
|
||||
<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} />
|
||||
</>,
|
||||
)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
|
||||
const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement
|
||||
panMap(container)
|
||||
clickOn(pin)
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-008: pressing a pin after a pan still holds the map still for a drag onto a day', async () => {
|
||||
const { container, pins } = await renderMap()
|
||||
const map = maps[maps.length - 1]
|
||||
panMap(container)
|
||||
const before = map.getCenter()
|
||||
// Press on the pin and move off it, as the start of a drag towards the day plan.
|
||||
act(() => {
|
||||
pins[0].dispatchEvent(mouse('mousedown', 500, 350))
|
||||
document.dispatchEvent(mouse('mousemove', 560, 390))
|
||||
document.dispatchEvent(mouse('mousemove', 620, 430))
|
||||
document.dispatchEvent(mouse('mouseup', 620, 430))
|
||||
})
|
||||
expect(map.getCenter()).toEqual(before)
|
||||
// The pan handler is back on, and the background still pans the map.
|
||||
expect(map.dragging.enabled()).toBe(true)
|
||||
panMap(container)
|
||||
expect(map.getCenter()).not.toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -599,6 +599,32 @@ 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.
|
||||
@@ -920,6 +946,14 @@ 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} />)
|
||||
@@ -1030,6 +1064,26 @@ 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,10 +330,11 @@ interface SelectionControllerProps {
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
dayPlaces: Place[]
|
||||
selectedPlace: Place | null
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
|
||||
const map = useMap()
|
||||
const prev = useRef(null)
|
||||
|
||||
@@ -343,7 +344,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
||||
// 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 = places.find(p => p.id === selectedPlaceId)
|
||||
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
|
||||
if (selected?.lat != null && selected?.lng != null) {
|
||||
const latlng: [number, number] = [selected.lat, selected.lng]
|
||||
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
|
||||
@@ -357,7 +358,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
}, [selectedPlaceId, places, map])
|
||||
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -547,11 +548,13 @@ 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()
|
||||
@@ -619,6 +622,32 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a place pin a drag source without losing its clicks to an old pan.
|
||||
*
|
||||
* The drag wiring swallows mousedown so the map holds still while a drag starts. Leaflet
|
||||
* tells a click from the end of a pan by a flag it only clears on the next mousedown it
|
||||
* sees, so once the map had been dragged, every click on a pin was taken for the tail of
|
||||
* that pan and dropped, until something else on the map was pressed (#2504). A press on
|
||||
* the pin now clears that flag itself, as the map would have. Switching the pan handler
|
||||
* off and on again is the public way to do that, and nothing is being panned at that
|
||||
* moment. The click then takes Leaflet's normal route, so it still closes open popups
|
||||
* and still reaches listeners further up the page, like an open context menu.
|
||||
*/
|
||||
function wireDraggablePin(el: HTMLElement, placeId: number, map: L.Map): () => void {
|
||||
const undoDrag = makeMarkerDraggable(el, placeId)
|
||||
const forgetPan = (e: MouseEvent) => {
|
||||
if (e.button !== 0 || !map.dragging.enabled()) return
|
||||
map.dragging.disable()
|
||||
map.dragging.enable()
|
||||
}
|
||||
el.addEventListener('mousedown', forgetPan)
|
||||
return () => {
|
||||
undoDrag()
|
||||
el.removeEventListener('mousedown', forgetPan)
|
||||
}
|
||||
}
|
||||
|
||||
interface MemoMarkerProps {
|
||||
place: any
|
||||
isSelected: boolean
|
||||
@@ -637,6 +666,7 @@ const MemoMarker = memo(function MemoMarker({
|
||||
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable, onRegister,
|
||||
}: MemoMarkerProps) {
|
||||
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
|
||||
const map = useMap()
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
// react-leaflet compares `position` by reference and calls setLatLng whenever it
|
||||
// differs, and the cluster group answers a moved child by taking it out and putting
|
||||
@@ -655,7 +685,7 @@ const MemoMarker = memo(function MemoMarker({
|
||||
// so the wiring is redone on every add rather than once on mount.
|
||||
add: (e: any) => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = draggable ? makeMarkerDraggable(e.target.getElement() as HTMLElement, place.id) : null
|
||||
cleanupRef.current = draggable ? wireDraggablePin(e.target.getElement() as HTMLElement, place.id, map) : null
|
||||
},
|
||||
remove: () => { cleanupRef.current?.(); cleanupRef.current = null },
|
||||
click: () => onClickPlace(place.id),
|
||||
@@ -677,6 +707,8 @@ 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,
|
||||
@@ -698,6 +730,9 @@ 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,
|
||||
@@ -761,8 +796,8 @@ export const MapView = memo(function MapView({
|
||||
</Marker>
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
|
||||
// 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
|
||||
@@ -1033,7 +1068,10 @@ 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.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
// 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()
|
||||
// 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
|
||||
@@ -1123,7 +1161,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} paddingOpts={paddingOpts} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
|
||||
|
||||
@@ -1379,6 +1379,33 @@ 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,6 +22,7 @@ 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'
|
||||
@@ -41,6 +42,7 @@ 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']
|
||||
@@ -146,6 +148,8 @@ 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
|
||||
@@ -163,6 +167,11 @@ 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[]
|
||||
@@ -670,6 +679,7 @@ export function MapViewGL({
|
||||
routeColors = null,
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
selectedPlaceId = null,
|
||||
selectedPlace = null,
|
||||
hoverDisabled = false,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -694,6 +704,7 @@ export function MapViewGL({
|
||||
showTransitRoutes = true,
|
||||
days = NO_DAYS,
|
||||
selectedDayId = null,
|
||||
scopeConnectionsToDay = false,
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
pois = NO_POIS,
|
||||
@@ -1159,6 +1170,12 @@ 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
|
||||
@@ -2179,8 +2196,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 })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
|
||||
@@ -2295,7 +2312,7 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !selectedPlaceId) return
|
||||
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
|
||||
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
|
||||
if (!target?.lat || !target?.lng) return
|
||||
try {
|
||||
map.flyTo({
|
||||
@@ -2382,9 +2399,6 @@ 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,6 +13,10 @@ 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
|
||||
@@ -1049,3 +1053,196 @@ 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, legAvoids, type AvoidClass } from './valhallaRoute'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
|
||||
import type { RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
@@ -216,6 +216,33 @@ 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[],
|
||||
@@ -480,9 +507,7 @@ export async function calculateRouteWithLegs(
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort((a, b) => a.localeCompare(b))
|
||||
: []
|
||||
const avoiding = avoidedClasses(profile, avoid)
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
// The cached result carries formatted leg distances, so the active distance unit is
|
||||
@@ -497,23 +522,30 @@ 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. `avoiding` is empty whenever the request goes to OSRM after all, so the key
|
||||
// also separates the two engines.
|
||||
// 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.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
|
||||
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const cacheKey = `${plainKey}${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
|
||||
@@ -558,6 +590,18 @@ 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' =
|
||||
@@ -592,9 +636,7 @@ export async function calculateRouteWithLegs(
|
||||
)
|
||||
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
cacheRoute(cacheKey, result)
|
||||
return result
|
||||
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
@@ -659,6 +701,7 @@ async function routeAvoidingWithLegs(
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
|
||||
hasFerry: run.total.hasFerry,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,7 +757,7 @@ function formatDuration(seconds: number): string {
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as OSRM offers it. */
|
||||
/** One way of driving a leg, as a router offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
@@ -724,20 +767,24 @@ export interface RouteAlternative {
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: AvoidClass
|
||||
/**
|
||||
* Which engine priced this route, when it was not the one that priced the rest.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
engine?: 'valhalla'
|
||||
engine?: RouteEngine
|
||||
/** Whether the way crosses by ferry, where the engine that drew it says so. */
|
||||
hasFerry?: boolean
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
@@ -780,10 +827,16 @@ 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.
|
||||
*/
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
const stray = furthestFrom(b.coordinates, a.coordinates)
|
||||
if (stray) return stray.km < SAME_ROAD_KM
|
||||
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
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
@@ -862,12 +915,18 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,12 +976,89 @@ 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 }: { signal?: AbortSignal; limit?: number } = {},
|
||||
{ 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,
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
@@ -960,11 +1096,6 @@ export async function calculateAlternatives(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -982,31 +1113,78 @@ 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.
|
||||
*/
|
||||
function furthestFrom(
|
||||
export function furthestFrom(
|
||||
line: [number, number][],
|
||||
reference: [number, number][],
|
||||
): { lat: number; lng: number; km: number } | null {
|
||||
): { index: number; 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 best: { lat: number; lng: number } | null = null
|
||||
let bestIndex = -1
|
||||
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 [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 }
|
||||
const near = nearestOn(line[i], reference, step)
|
||||
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
|
||||
}
|
||||
if (!best) return null
|
||||
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { ...best, km }
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns } from './dayRoutePlan'
|
||||
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
@@ -142,7 +142,85 @@ describe('buildDayRouteRuns', () => {
|
||||
expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-015: the hotel points say which end of the day they are, a stop on the same spot does not (#2501)', () => {
|
||||
const hotel = { id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(2, inputs({
|
||||
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })],
|
||||
assignments: { '2': [at(48.86, 2.35, 0), at(48.80, 2.30, 1), at(48.88, 2.36, 2)] },
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
const legs = runs.flatMap(run => run.slice(1).map((p, i) => hotelBookendOf(run[i], p) ?? null))
|
||||
// Out of the hotel, three stops (one of them on the hotel's own spot), back to it.
|
||||
expect(legs).toEqual(['morning', null, null, 'evening'])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-016: the one drive of a moving day without stops is its morning leg (#2476)', () => {
|
||||
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
||||
const stays = [
|
||||
{ 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 [run] = buildDayRouteRuns(2, inputs({ days, accommodations: stays, optimizeFromAccommodation: true }))
|
||||
expect(run.map(p => p.hotel)).toEqual(['morning', 'evening'])
|
||||
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
|
||||
})
|
||||
|
||||
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 '../../utils/geo'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
||||
@@ -13,6 +13,19 @@ export interface DayRoutePoint {
|
||||
isPlace: boolean
|
||||
leg_transport_mode?: string | null
|
||||
incoming_leg_transport_mode?: string | null
|
||||
/** Only on an accommodation bookend: the stay the day starts from or ends at. */
|
||||
hotel?: 'morning' | 'evening'
|
||||
}
|
||||
|
||||
/**
|
||||
* Which accommodation bookend the leg between two neighbouring run points is, if any.
|
||||
* A day that is nothing but the drive from one stay to the next counts as its morning
|
||||
* leg, so it shows once at the top rather than at both ends (#2476).
|
||||
*/
|
||||
export function hotelBookendOf(from: DayRoutePoint, to: DayRoutePoint): 'morning' | 'evening' | undefined {
|
||||
if (from.hotel === 'morning') return 'morning'
|
||||
if (to.hotel === 'evening') return 'evening'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Everything the plan depends on, passed in rather than read from the store, so the
|
||||
@@ -131,9 +144,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
}
|
||||
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
||||
// falls through to the day default for hotel-adjacent legs unless the place endpoint
|
||||
// carries its own override.
|
||||
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
|
||||
// carries its own override. It says which end of the day it is, so the legs routed
|
||||
// from it can be told apart from a stop planned on the hotel's own spot (#2501).
|
||||
const hotelPt = (a: Accommodation | undefined, hotel: 'morning' | 'evening'): DayRoutePoint | null =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false, hotel } : null
|
||||
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
|
||||
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
|
||||
// an arrival/departure day (#1321, #2133) all draw no bookend.
|
||||
@@ -154,8 +168,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
|
||||
const firstWay = flatPts[0]
|
||||
const lastWay = flatPts[flatPts.length - 1]
|
||||
const morningHotel = hotelPt(bookends?.morning)
|
||||
const eveningHotel = hotelPt(bookends?.evening)
|
||||
const morningHotel = hotelPt(bookends?.morning, 'morning')
|
||||
const eveningHotel = hotelPt(bookends?.evening, 'evening')
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
@@ -170,9 +184,14 @@ 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.
|
||||
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
|
||||
const m = hotelPt(bookends?.morning)
|
||||
const e = hotelPt(bookends?.evening)
|
||||
// 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) {
|
||||
const m = hotelPt(bookends?.morning, 'morning')
|
||||
const e = hotelPt(bookends?.evening, 'evening')
|
||||
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
|
||||
// The map pans on mousedown anywhere on its surface, and a marker sits on that
|
||||
// surface. Without this the map slides away under the pointer while the drag
|
||||
// is starting, which makes the day plan an impossible target to reach.
|
||||
// The map then never sees the press that starts a click on the marker, which is why the Leaflet
|
||||
// renderer clears the map's pan flag itself when a pin is pressed (wireDraggablePin, #2504).
|
||||
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
|
||||
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 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 { AssignmentsMap, RouteSegment } from '../../types'
|
||||
import type { Accommodation, AssignmentsMap, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>()
|
||||
@@ -212,4 +212,24 @@ 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, runFrom, legAvoids } from './valhallaRoute'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
@@ -365,3 +365,62 @@ 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,14 +204,7 @@ 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.
|
||||
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)
|
||||
}
|
||||
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal), 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.
|
||||
@@ -260,14 +253,39 @@ function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
/**
|
||||
* 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(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
): Record<string, unknown> {
|
||||
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
|
||||
@@ -275,13 +293,16 @@ async function requestRun(
|
||||
// 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
|
||||
const body = {
|
||||
return {
|
||||
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',
|
||||
@@ -299,12 +320,66 @@ async function requestRun(
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
return null
|
||||
}
|
||||
return runFrom(await response.json())
|
||||
return 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,6 +31,12 @@ 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(() => {
|
||||
@@ -257,7 +263,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 } as any
|
||||
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null, price: '120' } 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({
|
||||
@@ -269,6 +275,33 @@ 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,6 +16,7 @@ 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
|
||||
@@ -199,11 +200,34 @@ 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
|
||||
}
|
||||
|
||||
// `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) {
|
||||
/**
|
||||
* 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)
|
||||
const breaksPerDay = pageBreakPerDay()
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
@@ -335,14 +359,11 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
}
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
// 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).
|
||||
// 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).
|
||||
const assigned = (assignments[String(day.id)] || [])
|
||||
.filter((a: any) => a.accommodation_id == null)
|
||||
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.slice()
|
||||
.sort((a, b) => (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)
|
||||
|
||||
@@ -33,6 +33,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// An amount is printed in its own currency, unconverted. One saved without a
|
||||
// currency is in the trip's own (#2525), which is how Costs reads it; labelling
|
||||
// it with the display currency turned a 120 EUR deposit into $120.00.
|
||||
const ownCurrency = (currency: string | null | undefined) => currency || trip?.currency || base
|
||||
const linked = reservationId
|
||||
? budgetItems.find(i => i.reservation_id === reservationId)
|
||||
: placeId
|
||||
@@ -54,7 +58,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -72,7 +76,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, ownCurrency(linked.currency), locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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-080
|
||||
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-085
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -12,6 +12,7 @@ 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' });
|
||||
|
||||
@@ -519,6 +520,15 @@ 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(
|
||||
@@ -1896,3 +1906,80 @@ 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,6 +13,7 @@ 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'
|
||||
@@ -20,6 +21,7 @@ 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,
|
||||
@@ -453,7 +455,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 }}>{acc.confirmation}</div>
|
||||
<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(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>
|
||||
@@ -632,7 +634,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>
|
||||
<input type="text" value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
|
||||
<BookingCodeInput 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>
|
||||
@@ -659,7 +661,8 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
|
||||
{/* Place List */}
|
||||
<div style={{ maxHeight: 250, overflowY: 'auto' }}>
|
||||
{(() => {
|
||||
const filtered = hotelCategoryFilter ? places.filter(p => p.category_id === hotelCategoryFilter) : places
|
||||
const offered = stayPlaces<Place>(places, hotelForm.place_id)
|
||||
const filtered = hotelCategoryFilter ? offered.filter(p => p.category_id === hotelCategoryFilter) : offered
|
||||
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,14 +101,15 @@ vi.mock('../shared/Toast', () => ({
|
||||
|
||||
// ── Permissions mock ────────────────────────────────────────────────────────
|
||||
|
||||
// Flipped per test to render the read-only variants of the day rows.
|
||||
const mockPermissions = vi.hoisted(() => ({ canEdit: true }))
|
||||
// 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>() }))
|
||||
|
||||
vi.mock('../../store/permissionsStore', async (importOriginal) => {
|
||||
const actual = await importOriginal() as any
|
||||
return {
|
||||
...actual,
|
||||
useCanDo: () => () => mockPermissions.canEdit,
|
||||
useCanDo: () => (action: string) => mockPermissions.canEdit && !mockPermissions.denied.has(action),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -182,6 +183,7 @@ 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({
|
||||
@@ -938,6 +940,18 @@ 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 () => {
|
||||
@@ -2508,6 +2522,86 @@ 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'))
|
||||
@@ -2797,7 +2891,8 @@ 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.
|
||||
// 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])
|
||||
const undo = pushUndo.mock.calls[0][1] as () => Promise<void>
|
||||
await undo()
|
||||
expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12])
|
||||
@@ -3330,6 +3425,65 @@ 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' })
|
||||
@@ -3782,6 +3936,76 @@ 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')
|
||||
@@ -4154,6 +4378,7 @@ 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,
|
||||
getAssignmentReservations, timedSlot, storedRideSlot,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
@@ -56,6 +56,8 @@ 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'
|
||||
|
||||
@@ -76,11 +78,23 @@ 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
|
||||
@@ -102,7 +116,8 @@ 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
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => 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
|
||||
canUndo?: boolean
|
||||
lastActionLabel?: string | null
|
||||
onUndo?: () => void
|
||||
@@ -140,8 +155,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onReorderDays, onAddDay,
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
|
||||
onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
onToggleConnection,
|
||||
@@ -188,6 +203,9 @@ 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)
|
||||
@@ -429,7 +447,15 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
.slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
// Compute initial day_plan_position for a transport based on time
|
||||
const computeTransportPosition = (r, da) => {
|
||||
const computeTransportPosition = (r, da, dayId) => {
|
||||
// A ride that lands today goes where it adds the least road between the same clocks,
|
||||
// by the rule the road trip seats it with. This slot is stored, so seated by the
|
||||
// clock alone a ferry between two untimed stops stayed at the end of the day, and the
|
||||
// drive went overland before the crossing (#2461). Worked out over every stored row of
|
||||
// the day rather than `da`: the drive stops at the hotel and the service stops the
|
||||
// list hides, and the slot is read against their order indexes too.
|
||||
const ride = storedRideSlot(r, assignments[String(dayId)] || [], accommodations, dayId)
|
||||
if (ride !== null) return ride
|
||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||
// Find the last place with time <= transport time
|
||||
let afterIdx = -1
|
||||
@@ -453,7 +479,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
)
|
||||
const positions = sorted.map((r, idx) => ({
|
||||
id: r.id,
|
||||
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
|
||||
day_plan_position: computeTransportPosition(r, da, dayId) + idx * 0.01,
|
||||
}))
|
||||
// Mark as initialized immediately to prevent re-entry
|
||||
for (const p of positions) initedTransportIds.current.add(p.id)
|
||||
@@ -507,6 +533,19 @@ 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)
|
||||
@@ -863,7 +902,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const capturedPrevIds = prevAssignmentIds
|
||||
pushUndo?.(t('undo.reorder'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
}, [capturedDayId])
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
rollBackReservations()
|
||||
@@ -1038,7 +1077,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const capturedDayId = dayId
|
||||
pushUndo?.(t('undo.optimize'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||
})
|
||||
}, [capturedDayId])
|
||||
}
|
||||
|
||||
|
||||
@@ -1058,11 +1097,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
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId)
|
||||
moveToDay(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) {
|
||||
@@ -1099,6 +1138,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
dayAdd,
|
||||
onDeleteDay,
|
||||
deleteDayQuestion,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1148,6 +1190,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
tripActions,
|
||||
can,
|
||||
canEditDays,
|
||||
canEditPlaces,
|
||||
canManageShare,
|
||||
noteUi,
|
||||
setNoteUi,
|
||||
@@ -1212,6 +1255,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
getMergedItems,
|
||||
storedPositionBefore,
|
||||
placeBelowNote,
|
||||
moveToDay,
|
||||
mergedItemsMap,
|
||||
applyMergedOrder,
|
||||
handleMergedDrop,
|
||||
@@ -1299,6 +1343,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
dayAdd,
|
||||
onDeleteDay,
|
||||
deleteDayQuestion,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1348,6 +1395,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
tripActions,
|
||||
can,
|
||||
canEditDays,
|
||||
canEditPlaces,
|
||||
canManageShare,
|
||||
noteUi,
|
||||
setNoteUi,
|
||||
@@ -1412,6 +1460,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
getMergedItems,
|
||||
storedPositionBefore,
|
||||
placeBelowNote,
|
||||
moveToDay,
|
||||
mergedItemsMap,
|
||||
applyMergedOrder,
|
||||
handleMergedDrop,
|
||||
@@ -1556,6 +1605,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
canManageShare={canManageShare}
|
||||
onReorderDays={onReorderDays}
|
||||
onAddDay={onAddDay}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
deleteDayQuestion={deleteDayQuestion}
|
||||
/>
|
||||
|
||||
{/* Tagesliste */}
|
||||
@@ -1593,14 +1645,20 @@ 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.
|
||||
// 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.
|
||||
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)
|
||||
(transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng) &&
|
||||
(dayCarriers.length === 0 || dayHasLocatedCarrier)
|
||||
)
|
||||
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer
|
||||
/**
|
||||
@@ -1613,21 +1671,29 @@ 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 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 drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasLocatedCarrier)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasLocatedCarrier)
|
||||
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).
|
||||
@@ -1871,7 +1937,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) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
moveToDay(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) {
|
||||
@@ -1894,7 +1960,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
moveToDay(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) {
|
||||
@@ -2054,7 +2120,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) {
|
||||
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')))
|
||||
moveToDay(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)
|
||||
@@ -2097,13 +2163,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// does not exist here.
|
||||
const navTargets = getNavigationTargets(place)
|
||||
ctxMenu.open(e, [
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditPlaces && 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 },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
@@ -2478,7 +2544,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) {
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
moveToDay(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) {
|
||||
@@ -2662,7 +2728,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) {
|
||||
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')))
|
||||
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')))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
@@ -2773,7 +2839,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) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
moveToDay(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) {
|
||||
@@ -2798,7 +2864,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) */}
|
||||
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
|
||||
{showRouteTools && (
|
||||
<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"
|
||||
@@ -2831,10 +2897,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 */}
|
||||
<Tooltip label={t('planner.openGoogleMaps')} placement="top">
|
||||
{exportStops.length > 0 && <Tooltip label={t('planner.openGoogleMaps')} placement="top">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateGoogleMapsUrl(dayExportStops())
|
||||
const url = generateGoogleMapsUrl(exportStops)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
@@ -2847,14 +2913,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. */}
|
||||
<Tooltip label={t('planner.openCoMaps')} placement="top">
|
||||
{exportStops.length > 0 && <Tooltip label={t('planner.openCoMaps')} placement="top">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
|
||||
const url = generateCoMapsUrl(exportStops, day.default_transport_mode ?? routeProfile)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openCoMaps')}
|
||||
@@ -2867,7 +2933,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-023
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -233,6 +233,48 @@ 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,6 +5,8 @@ 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 {
|
||||
@@ -37,6 +39,12 @@ 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({
|
||||
@@ -44,7 +52,7 @@ export function DayPlanSidebarToolbar({
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -197,6 +205,9 @@ export function DayPlanSidebarToolbar({
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
deleteQuestion={deleteDayQuestion}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-028
|
||||
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.
|
||||
@@ -121,9 +125,9 @@ describe('DayReorderPopup', () => {
|
||||
fireEvent.dragStart(first)
|
||||
fireEvent.dragOver(second)
|
||||
// The hovered row is highlighted while a drag is in flight.
|
||||
expect(second.style.outline).toContain('dashed')
|
||||
expect(second).toHaveClass('outline-dashed')
|
||||
fireEvent.dragEnd(first)
|
||||
expect(second.style.outline).toBe('none')
|
||||
expect(second).not.toHaveClass('outline-dashed')
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -137,4 +141,225 @@ 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,6 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Trash2, AlertTriangle } 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 {
|
||||
@@ -10,29 +18,56 @@ 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, and add a day at the end. Day headers stay untouched — this is the
|
||||
* arrows, add a day, or delete one. Day headers stay untouched, so 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, onClose }: DayReorderPopupProps) {
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, deleteQuestion, 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 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 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 move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
@@ -42,106 +77,164 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
onReorder(ids)
|
||||
}
|
||||
|
||||
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
|
||||
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} />
|
||||
)
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-019
|
||||
// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-024
|
||||
import { useState } from 'react';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -29,11 +29,15 @@ 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 }: { initial?: LocationPoint | null; onPick?: (l: LocationPoint | null) => void }) {
|
||||
function Host({ initial = null, onPick, places }: { initial?: LocationPoint | null; onPick?: (l: LocationPoint | null) => void; places?: LocationPoint[] }) {
|
||||
const [value, setValue] = useState<LocationPoint | null>(initial);
|
||||
return <LocationSelect value={value} onChange={(l) => { setValue(l); onPick?.(l); }} />;
|
||||
return <LocationSelect value={value} onChange={(l) => { setValue(l); onPick?.(l); }} places={places} />;
|
||||
}
|
||||
|
||||
/** 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()} />);
|
||||
@@ -263,4 +267,77 @@ 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,9 +16,15 @@ 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 }: Props) {
|
||||
export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
@@ -81,11 +87,20 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
|
||||
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 || results.length === 0) return
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
|
||||
if (!open || rows.length === 0) return
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, rows.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
|
||||
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
|
||||
else if (e.key === 'Enter' && rows[highlight]) { e.preventDefault(); pick(rows[highlight]) }
|
||||
else if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
@@ -97,7 +112,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={placeholder ?? t('reservations.searchLocation')}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKey}
|
||||
className="bg-transparent text-content"
|
||||
@@ -110,14 +125,14 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (loading || results.length > 0) && (
|
||||
{open && ((loading && !showPicks) || rows.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 && results.length === 0 && (
|
||||
{loading && !showPicks && rows.length === 0 && (
|
||||
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div>
|
||||
)}
|
||||
{results.map((r, i) => (
|
||||
{rows.map((r, i) => (
|
||||
<button
|
||||
key={`${r.osm_id || r.google_place_id || i}`}
|
||||
key={showPicks ? `pick:${r.name}:${r.lat}:${r.lng}` : `${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 -080
|
||||
import { render, screen, waitFor, fireEvent, within } 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 -091
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -162,6 +162,88 @@ 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 () => {
|
||||
@@ -233,6 +315,81 @@ 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, sourceLabelFor } from '../../utils/placeSource'
|
||||
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -191,12 +191,17 @@ 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 { hasMapsKey, placesEnrichEnabled } = useAuthStore()
|
||||
const { placesEnrichEnabled } = useAuthStore()
|
||||
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
|
||||
const can = useCanDo()
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -317,6 +322,31 @@ 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(() => {
|
||||
@@ -397,13 +427,18 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
|
||||
const handleMapsSearch = async (provider?: 'google') => {
|
||||
if (!mapsSearch.trim()) return
|
||||
// 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
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
// A pasted Google Maps or Amap link resolves server-side into a place
|
||||
const trimmed = mapsSearch.trim()
|
||||
if (isMapUrl(trimmed)) {
|
||||
if (!provider && isMapUrl(trimmed)) {
|
||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
if (resolved.lat && resolved.lng) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
@@ -419,14 +454,16 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
|
||||
if (epoch !== searchEpochRef.current) return
|
||||
searchMetaRef.current = { query: trimmed, 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 {
|
||||
setIsSearchingMaps(false)
|
||||
if (epoch === searchEpochRef.current) setIsSearchingMaps(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +523,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
const epoch = searchEpochRef.current
|
||||
setMapsSearch('')
|
||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||
setIsSearchingMaps(true)
|
||||
@@ -507,6 +545,10 @@ 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, 東京駅"
|
||||
@@ -525,6 +567,7 @@ 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) {
|
||||
@@ -534,12 +577,17 @@ 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 {
|
||||
setIsSearchingMaps(false)
|
||||
placesSessionRef.current.end()
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,7 +808,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
language,
|
||||
locale,
|
||||
timeFormat,
|
||||
hasMapsKey,
|
||||
googleAnswers,
|
||||
placesEnrichEnabled,
|
||||
can,
|
||||
tripObj,
|
||||
@@ -838,7 +886,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
hasMapsKey,
|
||||
googleAnswers,
|
||||
placesEnrichEnabled,
|
||||
can,
|
||||
tripObj,
|
||||
@@ -1005,8 +1053,9 @@ 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
|
||||
that has a key and for a list Google did not already produce. */}
|
||||
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
|
||||
where Google holds the key slot and for a list Google did not
|
||||
already produce. */}
|
||||
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMapsSearch('google')}
|
||||
@@ -1147,9 +1196,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
options={[
|
||||
{ value: '', label: t('places.noCategory') },
|
||||
...(categories || []).map(c => ({
|
||||
// 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.
|
||||
// A string like form.category_id, so the picked option hands
|
||||
// back the same kind of value the form already keeps.
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
})),
|
||||
|
||||
@@ -8,9 +8,11 @@ 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1478,6 +1480,25 @@ 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
|
||||
@@ -1545,3 +1566,25 @@ 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,6 +10,7 @@ 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'
|
||||
@@ -32,6 +33,7 @@ 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'
|
||||
@@ -184,11 +186,21 @@ interface PlaceInspectorProps {
|
||||
export default function PlaceInspector({
|
||||
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
|
||||
assignments = {}, reservations = [], onEditTransport, onEditReservation,
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
|
||||
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, 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')
|
||||
@@ -949,7 +961,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 }}>{res.confirmation_number}</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>
|
||||
)}
|
||||
</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 tabs 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 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. */}
|
||||
{dayScoped && (
|
||||
<div
|
||||
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-105
|
||||
import { render, screen, waitFor, fireEvent, within, act } 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,
|
||||
@@ -625,6 +627,29 @@ 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' });
|
||||
@@ -1001,7 +1026,7 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText('Linked expense')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation' });
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation', currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => {
|
||||
@@ -1311,6 +1336,55 @@ describe('ReservationModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-103: a linked cost saved without a currency is shown in the trip currency, not the display one (#2525)', () => {
|
||||
// A booking's own cost entry is written without a currency, which means the
|
||||
// trip's. Reading in USD on a EUR trip must not turn the 120 EUR into $120.00.
|
||||
budgetEnabled();
|
||||
seedStore(useSettingsStore, { settings: { default_currency: 'USD' } });
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1, currency: 'EUR' }),
|
||||
budgetItems: [
|
||||
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||
expect(screen.getByText('120,00 €')).toBeInTheDocument();
|
||||
expect(screen.queryByText('$120.00')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-104: an imported price keeps the currency it was quoted in, in the preview and on save (#2525)', async () => {
|
||||
// A euro trip, a confirmation priced in dollars. The preview said $801.76 and the
|
||||
// save sent the amount alone, which the server stored as 801.76 EUR.
|
||||
budgetEnabled();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 82 });
|
||||
const prefill = hotelPrefill({ metadata: { price: 801.76, priceCurrency: 'usd' } });
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
|
||||
|
||||
expect(screen.getByText('$801.76')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 801.76, category: 'accommodation', currency: 'USD' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-105: a parsed currency that is not a code previews and saves in the trip currency', async () => {
|
||||
budgetEnabled();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 83 });
|
||||
const prefill = hotelPrefill({ metadata: { price: 50, priceCurrency: 'dollars' } });
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
|
||||
|
||||
// The server drops such a currency too, so the preview names the one it will store.
|
||||
expect(screen.getByText('50,00 €')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 50, category: 'accommodation' });
|
||||
});
|
||||
|
||||
// ── File error paths ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => {
|
||||
@@ -1566,4 +1640,57 @@ 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,6 +6,7 @@ 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'
|
||||
@@ -18,11 +19,13 @@ import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
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 },
|
||||
@@ -304,11 +307,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) saveData.create_budget_entry = entry
|
||||
}
|
||||
const saved = await onSave(saveData)
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
@@ -350,12 +350,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be
|
||||
// linked: the same entry the save sends, so the two cannot name different currencies.
|
||||
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
|
||||
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
@@ -559,7 +557,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>
|
||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -600,7 +598,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
placeholder={t('reservations.meta.pickHotel')}
|
||||
options={[
|
||||
{ value: '', label: '—' },
|
||||
...places.map(p => ({ value: p.id, label: p.name })),
|
||||
...stayPlaces(places, form.hotel_place_id).map(p => ({ value: p.id, label: p.name })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-064
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-070
|
||||
import { render, screen, waitFor, fireEvent, within, act } 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,
|
||||
@@ -39,8 +41,13 @@ vi.mock('./AirportSelect', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./LocationSelect', () => ({
|
||||
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 })} />
|
||||
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 })}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -88,6 +95,36 @@ describe('TransportModal', () => {
|
||||
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003b: shows a delete action when editing with onDelete', () => {
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} onDelete={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003c: no delete action when creating, or when onDelete is omitted', () => {
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
const { rerender } = render(<TransportModal {...defaultProps} reservation={null} onDelete={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
rerender(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003d: confirming delete calls onDelete then onClose', async () => {
|
||||
const onDelete = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} onDelete={onDelete} onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /delete/i }));
|
||||
expect(screen.getByText('Delete booking?')).toBeInTheDocument();
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||
|
||||
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
@@ -1083,7 +1120,9 @@ describe('TransportModal', () => {
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights' });
|
||||
// The parsed currency travels with the price (#2525), or the server would store
|
||||
// it in whatever the trip currency is.
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights', currency: 'EUR' });
|
||||
});
|
||||
|
||||
// ── File edge cases ─────────────────────────────────────────────────────────
|
||||
@@ -1357,4 +1396,108 @@ 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,11 +1,14 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo } 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 ConfirmDialog from '../shared/ConfirmDialog'
|
||||
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'
|
||||
@@ -14,8 +17,9 @@ 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 } from '../../utils/flightLegs'
|
||||
import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -55,14 +59,6 @@ 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 {
|
||||
@@ -172,6 +168,8 @@ interface TransportModalProps {
|
||||
files?: TripFile[]
|
||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||
onFileDelete?: (fileId: number) => Promise<void>
|
||||
/** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */
|
||||
onDelete?: () => void | Promise<void>
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
// Pre-fill a brand-new transport booking from a parsed import item (review-
|
||||
// before-save); like `reservation` for the form but stays in create mode.
|
||||
@@ -191,9 +189,11 @@ interface TransportModalProps {
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
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) {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onDelete, 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)
|
||||
@@ -207,6 +207,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// Manual vs Automated (public transit search) creation mode (#1065).
|
||||
const [automated, setAutomated] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
@@ -616,11 +617,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) (payload as Record<string, unknown>).create_budget_entry = entry
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
@@ -664,12 +662,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be
|
||||
// linked: the same entry the save sends, so the two cannot name different currencies.
|
||||
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
|
||||
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -732,15 +728,24 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||
size="2xl"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{!automated && (
|
||||
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}>
|
||||
<div>
|
||||
{!automated && reservation?.id && onDelete && (
|
||||
<button type="button" onClick={() => setShowDeleteConfirm(true)} className="text-danger" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '8px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Trash2 size={13} /> {t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{!automated && (
|
||||
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -903,7 +908,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
{writesFlightLegs && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||||
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
@@ -936,7 +941,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 })} />
|
||||
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} places={locationPicks} />
|
||||
</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 }}>
|
||||
@@ -984,7 +989,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
{writesTrainLegs && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||||
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1008,11 +1013,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 })} />
|
||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} places={locationPicks} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} places={locationPicks} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1056,6 +1061,7 @@ 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 }}>
|
||||
@@ -1119,7 +1125,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>
|
||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -1252,6 +1258,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
</form>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('reservations.confirm.deleteTitle')}
|
||||
message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onConfirm={async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete?.()
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ 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, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
trip, days, showServiceStops: true,
|
||||
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -61,6 +62,9 @@ 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
|
||||
@@ -87,7 +91,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 })
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The linked cost an imported booking's parsed price becomes, or null when it has none.
|
||||
*
|
||||
* The price keeps the currency it was quoted in (#2525). The forms used to send the
|
||||
* amount alone, so an imported $801.76 was stored as 801.76 in the trip's own currency
|
||||
* while the form had just previewed it in dollars. A currency that is not a three-letter
|
||||
* code is left out, which is what the server does with one too, so the preview and the
|
||||
* saved cost read the same either way.
|
||||
*/
|
||||
export function importedPriceEntry(
|
||||
metadata: unknown,
|
||||
type: string,
|
||||
): { total_price: number; category: string; currency?: string } | null {
|
||||
const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
|
||||
const price = Number(meta.price)
|
||||
if (!Number.isFinite(price) || price <= 0) return null
|
||||
const code = typeof meta.priceCurrency === 'string' ? meta.priceCurrency.trim().toUpperCase() : ''
|
||||
return {
|
||||
total_price: price,
|
||||
category: typeToCostCategory(type),
|
||||
...(/^[A-Z]{3}$/.test(code) ? { currency: code } : {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
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,9 +5,10 @@ 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..009 — the ways of driving one leg, offered over the map.
|
||||
* FE-ALTBAR-001..014: the ways of driving one leg, offered over the map.
|
||||
*
|
||||
* Every value shown comes from the same overlay the map draws. Working them out
|
||||
* here separately is what once made the list label the driven route "Fastest"
|
||||
@@ -26,14 +27,13 @@ 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 => ({
|
||||
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
|
||||
})
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => openLeg({ dayId: 4, drive: { kind: 'leg', index: 1 }, ...over })
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
|
||||
@@ -144,18 +144,88 @@ describe('RoadtripAlternativesBar', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-010: an offer the other engine timed says so, and the rest do not', () => {
|
||||
it('FE-ALTBAR-010: an offer the other engine timed says so, naming that engine, and the rest do not', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[
|
||||
overlay({ note: 'Fastest' }),
|
||||
overlay({ index: 1, note: 'No tolls', otherEngine: true }),
|
||||
overlay({ index: 1, note: 'No tolls', otherEngine: true, engine: 'valhalla' }),
|
||||
]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByLabelText(/avoidance router/i)).toHaveLength(1)
|
||||
expect(screen.queryByLabelText(/main router/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-011: while a choice is checked and saved the list stands still, and the one in hand says so', () => {
|
||||
// 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 } from 'lucide-react'
|
||||
import { Shuffle, X, AlertTriangle, Info, Loader2 } 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 { alternativesPhase, alternativeSubline, type AlternativeOverlay } from './alternativeOverlays'
|
||||
import { alternativesBusy, alternativesPhase, alternativeSubline, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
|
||||
|
||||
interface RoadtripAlternativesBarProps {
|
||||
open: LegAlternatives | null
|
||||
@@ -35,6 +35,13 @@ 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">
|
||||
@@ -64,48 +71,61 @@ 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">
|
||||
{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)}
|
||||
<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>
|
||||
</span>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{alternativeSubline(alt, time => t('roadtrip.alt.slower', { time }))}
|
||||
</span>
|
||||
</span>
|
||||
{/* Only where the drive time on the map came from the other engine. The
|
||||
road and the length are the same question either way; the time is not,
|
||||
and two engines' times sitting next to each other on the map invite a
|
||||
subtraction that means nothing. Saying so here, where the choice is
|
||||
actually made, is cheaper than a second request per offer to restate
|
||||
the whole list in one engine's terms. */}
|
||||
{alt.otherEngine ? (
|
||||
<Tooltip label={t('roadtrip.alt.otherEngine')}>
|
||||
<Info size={12} className="shrink-0 text-content-faint" aria-label={t('roadtrip.alt.otherEngine')} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{/* Only where the drive time on the map came from the other engine. The
|
||||
road and the length are the same question either way; the time is not,
|
||||
and two engines' times sitting next to each other on the map invite a
|
||||
subtraction that means nothing. Saying so here, where the choice is
|
||||
actually made, is cheaper than a second request per offer to restate
|
||||
the whole list in one engine's terms. */}
|
||||
{note ? (
|
||||
<Tooltip label={t(note)}>
|
||||
<Info size={12} className="shrink-0 text-content-faint" aria-label={t(note)} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Kept in the tree while empty, so a screen reader is listening before it speaks. */}
|
||||
<p role="status" className={status ? 'text-caption text-content-muted' : 'sr-only'}>{status}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { fireEvent, render, screen, within } from '../../../tests/helpers/render
|
||||
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
||||
|
||||
/**
|
||||
* FE-CATPICK-001..008 — what the corridor is asked to look for.
|
||||
* FE-CATPICK-001..009 — 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. The summary names the
|
||||
* kinds rather than counting them, because "Fuel, Charging" is the answer and
|
||||
* "2 kinds" is a riddle.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const KEYS = ['fuel', 'charging', 'food'] as const
|
||||
@@ -40,11 +40,18 @@ describe('RoadtripCategoryPicker', () => {
|
||||
expect(screen.getByText('Looking for')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => {
|
||||
it('FE-CATPICK-003: closed, it names the kinds in the order they are offered, without printing them', () => {
|
||||
// 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(screen.getByText('Fuel, Food')).toBeInTheDocument()
|
||||
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)
|
||||
})
|
||||
|
||||
it('FE-CATPICK-004: opening lists every kind', () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React 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'
|
||||
|
||||
@@ -13,82 +15,68 @@ interface RoadtripCategoryPickerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* What to look for, as one full-width control.
|
||||
* What to look for, as one line.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture
|
||||
* 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.
|
||||
* rather than two round trips through a menu.
|
||||
*/
|
||||
export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
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 { open, setOpen, ref } = useDropdown<HTMLDivElement>()
|
||||
|
||||
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. 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')
|
||||
// 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(', ')
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{open ? (
|
||||
<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"
|
||||
>
|
||||
<div role="listbox" aria-multiselectable className={`${DROPDOWN_LIST} start-0`}>
|
||||
{keys.map(key => {
|
||||
const { labelKey, Icon } = meta[key]
|
||||
const on = selected.includes(key)
|
||||
@@ -101,15 +89,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="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
className={DROPDOWN_ROW}
|
||||
>
|
||||
<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 ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
|
||||
<span className={`min-w-0 flex-1 truncate pe-4 ${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,6 +138,25 @@ 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)])} />)
|
||||
@@ -512,8 +531,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, and the day picker beside it is
|
||||
// the part somebody came here to change.
|
||||
// The heading repeats what the column already is; the controls are what 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,6 +11,7 @@ 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'
|
||||
@@ -296,9 +297,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 in the
|
||||
* header rather than among the filters — it is the question's subject, not one of its
|
||||
* conditions.
|
||||
* 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.
|
||||
*/
|
||||
export default function RoadtripCorridorPanel({
|
||||
corridor, routes, onAddPoi, onFocusPoint, onAddManual, tripId, canImport,
|
||||
@@ -319,11 +320,6 @@ 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)
|
||||
@@ -378,40 +374,37 @@ 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, 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. */}
|
||||
{/* 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. */}
|
||||
<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>
|
||||
)}
|
||||
{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} />}
|
||||
{tripId !== undefined && tripId > 0 && canImport && (
|
||||
<span className="ms-auto"><GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} /></span>
|
||||
)}
|
||||
</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>
|
||||
<RoadtripCategoryPicker
|
||||
keys={CORRIDOR_CATEGORY_KEYS}
|
||||
meta={CATEGORY_META}
|
||||
selected={corridor.categories}
|
||||
onToggle={corridor.toggleCategory}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<span className="h-px bg-edge-faint" aria-hidden />
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
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,6 +277,59 @@ 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'))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -313,6 +366,16 @@ 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, { useState } from 'react'
|
||||
import React, { useId, useState } from 'react'
|
||||
import {
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
|
||||
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
|
||||
@@ -18,6 +18,9 @@ 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.
|
||||
@@ -162,21 +165,30 @@ 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, on, disabled, onToggle }: {
|
||||
function AvoidRow({ icon: Icon, label, hint, 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="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
<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>
|
||||
{disabled
|
||||
? <span className="text-caption text-content-faint">{'—'}</span>
|
||||
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
|
||||
: <ToggleSwitch on={on} onToggle={onToggle} label={label} describedBy={hint ? hintId : undefined} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -281,6 +293,7 @@ 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
|
||||
@@ -385,7 +398,10 @@ 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,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
// 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 }[]
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -409,21 +425,13 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
</span>
|
||||
{badges.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{badges.map(({ key, Icon, text }) => (
|
||||
<span
|
||||
{badges.map(({ key, Icon, text, label }) => (
|
||||
<FigureBadge
|
||||
key={key}
|
||||
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>
|
||||
lead={label ? <Icon size={9} aria-label={label} /> : <Icon size={9} aria-hidden />}
|
||||
value={text}
|
||||
tooltip={label}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
@@ -657,6 +665,18 @@ 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,7 +1,15 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||
import { act, 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'
|
||||
@@ -80,6 +88,31 @@ 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
|
||||
@@ -102,6 +135,22 @@ 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.
|
||||
@@ -585,11 +634,16 @@ 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', () => {
|
||||
const ferry = leg({ mode: 'ferry' })
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
|
||||
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()
|
||||
|
||||
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
|
||||
// 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()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
|
||||
@@ -706,31 +760,6 @@ 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,
|
||||
@@ -1131,13 +1160,24 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
})
|
||||
}
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
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('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()
|
||||
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()
|
||||
// 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()
|
||||
@@ -1149,9 +1189,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.getAllByLabelText('Open booking')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
|
||||
fireEvent.click(screen.getByText('Hamburg Airport'))
|
||||
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
|
||||
fireEvent.click(screen.getByText('LH 2020'))
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(70)
|
||||
// One shuffle: the road into the departure terminal. Not the ride, not the road out
|
||||
@@ -1159,6 +1199,17 @@ 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({
|
||||
@@ -1173,12 +1224,68 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
},
|
||||
})
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
|
||||
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)
|
||||
// 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.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)', () => {
|
||||
@@ -1194,9 +1301,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
|
||||
|
||||
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 rentalDay = () => {
|
||||
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
|
||||
const rental = day({
|
||||
return day({
|
||||
stops,
|
||||
legs: [leg(), leg(), leg()],
|
||||
schedule: {
|
||||
@@ -1209,11 +1316,22 @@ 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: [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()
|
||||
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()
|
||||
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Sixt Airport'))
|
||||
@@ -1223,6 +1341,15 @@ 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 = [
|
||||
@@ -1233,7 +1360,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} />)
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} canEditBookings />)
|
||||
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
|
||||
expect(screen.getByText('19:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
|
||||
@@ -1247,4 +1374,443 @@ 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, alternativesPhase, buildAlternativeOverlays, type AlternativeOverlay } from './alternativeOverlays'
|
||||
import { alternativeSubline, alternativesBusy, alternativesPhase, buildAlternativeOverlays, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
|
||||
/**
|
||||
* FE-ALTOVL-001..017: turning the router's answers into something drawable.
|
||||
* FE-ALTOVL-001..023: turning the router's answers into something drawable.
|
||||
*
|
||||
* Two decisions live here and neither is cosmetic: which of the offered roads is
|
||||
* drawn as the one you are on, and where each label hangs. A label anchored on a
|
||||
@@ -221,6 +221,56 @@ 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', () => {
|
||||
@@ -253,5 +303,60 @@ 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 } from '../Map/RouteCalculator'
|
||||
import type { RouteAlternative, RouteEngine } from '../Map/RouteCalculator'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
|
||||
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
|
||||
@@ -24,6 +24,8 @@ export interface AlternativeOverlay {
|
||||
* `slowerThanQuickest` stays zero.
|
||||
*/
|
||||
otherEngine: boolean
|
||||
/** The engine that priced this route, which names the other engine when it is one. */
|
||||
engine: RouteEngine
|
||||
labelBg: string
|
||||
/** Where to hang the label — a point on this route and on no other. */
|
||||
at: { lat: number; lng: number }
|
||||
@@ -88,29 +90,35 @@ 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 when there is one.
|
||||
// first entry, since the road currently driven is put at the top.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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 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)
|
||||
// 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)
|
||||
const quickest = comparable.length
|
||||
? comparable.reduce((best, i) => (routes[i].duration < routes[best].duration ? i : best), comparable[0])
|
||||
: 0
|
||||
@@ -119,10 +127,11 @@ 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: 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
|
||||
// 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
|
||||
return {
|
||||
index,
|
||||
coordinates: route.coordinates,
|
||||
@@ -146,10 +155,11 @@ 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 || !!routes[quickest].engine
|
||||
slowerThanQuickest: otherEngine || engineOf(routes[quickest]) !== railEngine
|
||||
? 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 },
|
||||
}
|
||||
@@ -189,5 +199,33 @@ export function alternativesPhase(
|
||||
* instead of each keeping its own.
|
||||
*/
|
||||
export function alternativeSubline(alt: AlternativeOverlay, slower: (time: string) => string): string {
|
||||
return alt.note || slower(formatDurationShort(alt.slowerThanQuickest))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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,15 +1,18 @@
|
||||
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { formatClock, formatDurationShort, parseClock, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../types'
|
||||
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'
|
||||
|
||||
/**
|
||||
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
|
||||
* the phone chain (#2428).
|
||||
* the phone chain (#2428), and which of the bookings under a stop open there.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* The icons are the booking panel's for the same types, so a flight looks like a flight
|
||||
* wherever the trip shows it.
|
||||
*/
|
||||
@@ -27,21 +30,198 @@ export function carrierIcon(type: string): LucideIcon {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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 })
|
||||
export function rideDuration(seg: RouteSegment | undefined): string | null {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string {
|
||||
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
|
||||
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
|
||||
return duration ? `${carrier.title} · ${duration}` : carrier.title
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
|
||||
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip'
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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,3 +175,73 @@ 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,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
arrivingReroutable,
|
||||
bookendReading,
|
||||
destinationCount,
|
||||
firstStopOfPlace,
|
||||
legReroutable,
|
||||
movableWithin,
|
||||
pickWarning,
|
||||
resumeFoldsIntoBookend,
|
||||
roadtripRows,
|
||||
stageClocks,
|
||||
stageEnd,
|
||||
@@ -13,9 +17,9 @@ import {
|
||||
type StopRow,
|
||||
} from './roadtripRowModel'
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { BookendPhase, NightBookend, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-RTROW-001 to FE-RTROW-044
|
||||
// FE-RTROW-001 to FE-RTROW-067
|
||||
|
||||
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
return {
|
||||
@@ -227,14 +231,16 @@ 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 }] },
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -242,6 +248,32 @@ 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])
|
||||
@@ -604,4 +636,229 @@ 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 type { CarrierTerminal, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { isCarrierMode, isStoredStop, type BookendPhase, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { readStay } from './stayReading'
|
||||
|
||||
/**
|
||||
@@ -21,6 +21,12 @@ 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
|
||||
@@ -48,6 +54,76 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,17 +156,18 @@ export function stageOf(days: readonly RoadtripDay[], dayId: number | null): Roa
|
||||
return days.find(d => d.dayId === dayId) ?? null
|
||||
}
|
||||
|
||||
function stopRow(
|
||||
stop: RoadtripStop,
|
||||
index: number,
|
||||
number: number | null,
|
||||
schedule: RoadtripDay['schedule'],
|
||||
driveWarnings: readonly ScheduleWarning[],
|
||||
): StopRow {
|
||||
function stopRow(day: RoadtripDay, index: number, number: number | null): 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 entry = schedule.entries[index]
|
||||
const mine = driveWarnings.filter(w => w.index === index)
|
||||
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)
|
||||
return {
|
||||
kind: 'stop',
|
||||
stop,
|
||||
@@ -103,6 +180,7 @@ function stopRow(
|
||||
warning: pickWarning(mine),
|
||||
dwellMinutes: readStay(stop, entry).minutes,
|
||||
offRoadMeters: stop.offRoadMeters ?? null,
|
||||
bookend: bookendReading(day, index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +234,20 @@ 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)
|
||||
@@ -176,6 +268,8 @@ 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',
|
||||
@@ -188,8 +282,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
index: i,
|
||||
carrier: stop.carrier,
|
||||
seg: day.legs[i],
|
||||
departure: stopRow(stop, i, null, day.schedule, day.driveWarnings),
|
||||
arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings),
|
||||
departure: stopRow(day, i, null),
|
||||
arrival: stopRow(day, i + 1, null),
|
||||
})
|
||||
// The road out of the arrival belongs to this row too; the pass below only
|
||||
// looks at the index it is on.
|
||||
@@ -200,7 +294,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(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings))
|
||||
rows.push(stopRow(day, i, unnumbered ? null : number))
|
||||
}
|
||||
|
||||
pushLeg(i)
|
||||
@@ -248,6 +342,36 @@ 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. */
|
||||
@@ -255,6 +379,15 @@ 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.
|
||||
*
|
||||
@@ -284,7 +417,7 @@ export function destinationCount(day: RoadtripDay): number {
|
||||
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 rows) {
|
||||
for (const row of unfoldRides(rows)) {
|
||||
if (row.kind === 'stop' && row.time) {
|
||||
start ??= row.time
|
||||
arrive = row.time
|
||||
@@ -296,7 +429,8 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
|
||||
}
|
||||
|
||||
/**
|
||||
* The stop a stage ends on: the last one the chain draws, a service stop included.
|
||||
* The stop a stage ends on: the last one the chain draws, a service stop or the arrival
|
||||
* terminal of a ride 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
|
||||
@@ -308,8 +442,9 @@ 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 {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i]
|
||||
const unfolded = unfoldRides(rows)
|
||||
for (let i = unfolded.length - 1; i >= 0; i--) {
|
||||
const row = unfolded[i]
|
||||
if (row.kind === 'stop') return row
|
||||
}
|
||||
return null
|
||||
@@ -326,12 +461,13 @@ 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. Null when none of the days stops at the place, which is a pin the road
|
||||
* 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
|
||||
* 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 => !s.automaticNight && !s.carrier && s.placeId === placeId)
|
||||
const stop = day.stops.find(s => isStoredStop(s) && s.placeId === placeId)
|
||||
if (stop) return stop
|
||||
}
|
||||
return null
|
||||
@@ -355,7 +491,7 @@ export function upNextStop(
|
||||
isToday: boolean,
|
||||
): { row: StopRow; minutesUntil: number } | null {
|
||||
if (!day || !isToday) return null
|
||||
const timed = roadtripRows(day)
|
||||
const timed = unfoldRides(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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user