Compare commits

..

34 Commits

Author SHA1 Message Date
github-actions[bot] 515398f8ee chore: bump version to 4.2.1 [skip ci] 2026-09-06 14:13:46 +00:00
Maurice e4f2eaf336 fix(trips): stop the two date fields overlapping in the trip dialog
A grid item defaults to min-width:auto, so the two date cells never went below
the width of their contents: a long locale date plus the keyboard button beside
it pushed each cell past its track, and on a phone the end date came to sit on
top of the start date with its own button clipped off the edge. Measured on a
412px viewport, the row overflowed its container by 30px.

Both cells get min-w-0 so the track wins, and the picker's own trigger gets it
too, which is what finally lets the ellipsis it already carries do its job
rather than the button simply growing. "12. Sept. 2026" now reads "12. Sept…"
in a narrow column instead of escaping it.

Keeping the locale format: the report suggests ISO 8601, but the date is
formatted from the user's language everywhere in the app, and the overlap is
the layout, not the wording.
2026-09-06 16:11:52 +02:00
Maurice 16fee0800d fix(test): repair the escaped newlines and wire the new controller arg
The stderr and --version fixtures had literal line breaks in them, and the
admin controller helper still built the controller with eight arguments.
2026-09-06 16:11:52 +02:00
Maurice 888cd4ee08 fix(llm): let a model refuse temperature, and say which extractor is installed
Two reports, both about an import that fails without saying why.

#2262 — temperature: 0 sat in the shared request body, so it went out on every
attempt including both retries. Reasoning models (the gpt-5 family) reject any
explicit temperature, and the json_object retry fires on any 400, so the import
spent two doomed requests and could not succeed at all. The parameter is now
dropped once a server has explicitly said it will not take it.

Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND
temperature, the API names one parameter per response, and it may name either
first — a chain of one-shot ifs converges on only one of the two orders. The
retries are a loop over what the server actually said instead: each remedy
applies at most once and they compose. Detection is a phrase, not the bare word,
because dropping temperature costs the deterministic sampling that small local
models depend on, and an unfamiliar wording still falls through to the
json_object retry exactly as before. NuExtract and Ollama are untouched: the
first attempt always carries temperature, and it is the only one they ever see.

#2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen
months behind upstream, and the reporter is right that this is invisible: a
stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely
unsupported provider all look the same from the UI, which is nothing imported
and no reason given. The version now appears in the startup log and in a new
admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the
extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the
filter drops are the only evidence that a vendor script is missing or failing.
Capped at 200 lines, and opt-in because those lines can carry document
fragments.

Not bumping the package: the filter the reporter needs first ships in KDE Gear
26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6
6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6
from unstable into the production image, on two architectures, that still would
not fix the reported carrier. It comes for free with the base-image move after
Debian 14.
2026-09-06 16:11:52 +02:00
Maurice 8fe90d1783 fix(test): the card is labelled Total trip spend 2026-09-06 16:11:52 +02:00
Maurice 4cda3c4b2e test: pin the empty-metadata payload and the payer-less share
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
2026-09-06 16:11:52 +02:00
Maurice bc85ec1eea fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.

The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).

Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).

App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.

LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.

And a typo in the Russian offline copy: Завершёна → Завершена.
2026-09-06 16:11:52 +02:00
Maurice 04c22b34af fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 16:11:52 +02:00
Maurice 56a23cb809 test(ssrf): pin what a redirect hop may carry across an origin
The credential strip and the 303/302 body downgrade are new behaviour in a
security helper, and src/utils sits on a 96% line ratchet that the untested
branches took under.
2026-09-06 16:11:52 +02:00
Maurice 251378be1d fix(security): keep the operator's ntfy token in and re-check every redirect hop
Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
2026-09-06 16:11:52 +02:00
Maurice 973ce5f8ff fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.

Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
2026-09-06 16:11:52 +02:00
Maurice bbc32310e9 test(journey): pin the viewless-map window the photo layer fell into
The Leaflet mock had no getCenter, so the new guard bailed out of every
draw and FE-COMP-JOURNEYMAP-046/047 went red. Give the mock a loaded map
by default and add FE-COMP-JOURNEYMAP-050, which takes the view away for
exactly the window #2254 hit: render must survive it drawing nothing, and
the moveend listener must still put the thumbnail up once fitBounds lands.
2026-09-06 16:11:52 +02:00
xvilo ad6e51c574 fix(journey): guard photo layer draw against map with no view yet
Public journey pages ≥1024px render the sticky sidebar map immediately with photos, before the deferred fitBounds/setView lands, causing latLngToContainerPoint to throw "Set map center and zoom first."
2026-09-06 16:11:52 +02:00
xvilo 6da11994d9 fix(app): stop mounting authed-only widgets on public visitor routes
SystemNoticeHost, BackgroundTasksWidget, and save-to-collection were gated only on login/register/password routes, so anonymous visitors to /shared/:token and /public/journey/:token still mounted them and hit system-notices/active with no session, logging a 401 on every visit.
2026-09-06 16:11:52 +02:00
Maurice eef1f16836 test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-06 16:11:52 +02:00
Maurice 4a8d77c59c fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.

Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.

#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.

#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.

Closes #2247
Closes #2248
Closes #2249
2026-09-06 16:11:52 +02:00
Maurice a70c2eb9e0 fix(collections): read the created list off the response the server actually sends
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.

The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.

Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.

The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
2026-09-06 16:11:52 +02:00
Maurice 99b040f684 fix(planner): never link an offline temp place id to a hotel booking
The envelope fix routes the created place through the store, which offline
mints a NEGATIVE temp id: reservations have no offline write path, the
mutation queue rewrites temp ids in a URL but never inside another entity's
body, and day_accommodations.place_id carries a foreign key. So a
forced-offline save (the Settings toggle sets the flag while the network is
still live) reached the server with place_id: -1758..., failed the FK inside
the insert transaction and rolled the whole booking back into a 500 — where
dev merely saved it unlinked. Worse, the temp place is in `places` by then,
so every retry matched the same negative id and the booking stayed unsavable
for the rest of the session.

Offline the function now links nothing at all and skips the geocode too: the
booking cannot be written either way, so minting a place would only leave an
orphan behind on the next flush. The name-match arm rejects temp ids for the
same reason. `as never` is gone from the addPlace argument, and
placesApi.create finally declares its `{ place }` return type — an untyped
`r.data` is what let the envelope be misread in the first place.
2026-09-06 16:11:52 +02:00
Xheldon 944e0a0970 fix(planner): link the place created for a hotel instead of minting a new one on every save
resolveImportedPlace created the hotel's place through placesApi.create and
read .id off the response, but that endpoint answers { place }, so the id was
undefined: the accommodation was saved without a place_id and the new place
was orphaned. Because the planner's places state never learned about it, the
name match failed on the next edit too, and every save of the same hotel
produced another copy (five in a row on one trip).

Create the place through the store's addPlace instead. It unwraps the
envelope, so the accommodation is linked, and it pushes the place into
`places`, so a later save of the same hotel reuses it. The mobile sheet goes
through the same handler and is fixed with it.

The hook tests mocked placesApi.create with a bare { id } and therefore
never saw the envelope; they now assert on addPlace, and a new case saves the
same hotel twice and expects one place.

Closes #2243
2026-09-06 16:11:52 +02:00
Maurice b9f69b0c76 test(login): stub the world animation so the takeoff assertions stop flaking
FE-PAGE-LOGIN-014 failed intermittently in CI while passing in isolation:
twice in a row on unrelated PRs, each time costing a full run, and once on
one of two runs of the identical commit.

The takeoff overlay renders LoginWorld, which starts three
requestAnimationFrame loops on mount. Under the load of the full suite that
was enough to push the overlay assertion past waitFor's default timeout.
Nothing in this file asserts on the animation, and LoginWorld has its own
tests, so the file stubs it.
2026-09-06 16:11:52 +02:00
Maurice fcefc7e771 test(maps): pin the RTL registration, and correct the precache figure
The guard in rtlText.ts had tests; the two lines that call it did not.
setRTLTextPlugin appears at exactly two places in the client and no test
touched either, so deleting a call, swapping an argument or dropping the
deferred flag left the whole suite green. The engines are mocked because
importing them for real builds a worker pool under jsdom, and the plugin
URL is mocked so the payload never loads.

The precache comment in vite.config.js was three releases stale, and it is
the only record in the repo of what a service-worker install costs. A real
build says 463 entries / 23,292 KiB, the plugin among them: it matches the
js glob, so it is fetched at install by every user rather than on first RTL
text as the branch description claims. That trade is the right one, since
the alternative is a CDN the production CSP refuses and an unshaped map for
anyone offline. Only the description of it was wrong.

Measured against a real client build, which no CI workflow performs: none
of them runs vite build or check:gl-split, so nothing here was covered
before.
2026-09-06 16:11:52 +02:00
Mohamed Elkmeshi 6f0a171c74 fix(maps): register the RTL text plugin so Arabic labels join
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as
isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" —
because neither GL engine loads its optional RTL text plugin by default and
TREK never registered one. Raster basemaps hid the problem, since their
labels are drawn server-side, so it only became visible for everyone when
the default basemap became a vector style.

Both engine entry points now register the plugin lazily, so the payload is
only fetched once a map actually paints RTL text and Latin-only users
download nothing. The call is left with each engine because the two
signatures genuinely differ: MapLibre takes (url, lazy) and returns a
promise, Mapbox takes (url, callback, deferred).

The plugin is bundled rather than loaded from a CDN: TREK is offline-first
and the production CSP is script-src 'self', so a remote plugin URL would be
refused by the worker that imports it. Bundling also puts it in the Workbox
precache, so shaping keeps working offline. It needs a resolve alias because
the package publishes only its ESM/WASM entry point through "exports" while
setRTLTextPlugin requires the classic script in dist/ — the alias is shared
by the build and test configs so the two cannot drift.
2026-09-06 16:11:52 +02:00
Maurice 3e1c06a0ee fix(reservations): keep a mirrored booking price without keying it on the expense
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.

The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.

What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.

The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
2026-09-06 16:11:52 +02:00
Xheldon 1aafab83a5 fix(reservations): keep the linked expense price when a booking is edited
A booking's linked expense price lives in reservations.metadata.price
(written by BudgetService.syncReservationPrice). The booking form rebuilds
metadata from its fields on every save and only carries transit /
airtrail_ids over, so PUT /api/trips/:tripId/reservations/:id overwrote the
stored metadata and the price vanished from the card until the expense was
re-saved.

ReservationsService.update() now carries price / priceCurrency from the
stored metadata into an incoming metadata object that does not set them,
as long as a budget item is still linked to the booking. A payload that
sets price itself, a booking without a linked expense, and an explicit
metadata: null behave exactly as before.

Closes #2233
2026-09-06 16:11:52 +02:00
Maurice c1d0435480 fix(app-config): refuse an LLM ceiling that would become a zero timeout
LLM_TIMEOUT_MS was validated as "a positive number", which lets two shapes
through that then degrade into an immediate abort rather than a long one.
A fractional value passes the check and floors to 0 in derive, which makes
the dispatcher fall back to undici's default and setTimeout(abort, 0) kill
every model call on the next tick. Anything past 2^31-1 does the same,
because Node clamps the delay to 1 ms. Both now refuse at boot, which is
what the wiki row already promised.

Also pins the seam the three deleted constants used to occupy: the clients'
abort deadline has to come from the configured ceiling. Nothing else in the
suite would have noticed a literal coming back.
2026-09-06 16:11:52 +02:00
Maurice 798859517f docs(wiki): the AI parsing addon now reads one environment variable
Three shipped sentences became false with LLM_TIMEOUT_MS: two claiming the
addon needs no environment variables (one of them in the very file the
variable was added to), and one naming the old five-minute ceiling that the
deleted constant used to set.
2026-09-06 16:11:52 +02:00
RemEx d3fdf78db6 fix(llm-parse): one configurable ceiling for a model call, and undici honours it
The timeout for a model call was spelled three times and adjustable
nowhere: 120s in the Anthropic client, 300s in the OpenAI-compatible one,
300s in the Ollama one. None of them was the ceiling that actually
applied, either — every one of those calls goes through the pinned undici
dispatcher, and an undici Agent stops waiting for response headers after
five minutes by default, so a client constant set above that never got
the chance to fire.

- LLM_TIMEOUT_MS (default 900000) replaces the three constants and is
  passed to the dispatcher as headersTimeout/bodyTimeout, so the abort
  signal and undici are governed by the same setting. The default is
  deliberately generous: heavier parsing work should fit without a code
  change, and an admin who would rather fail fast can lower it.
- The ceiling stays on the model lane. safeFetchAdminConfigured is also
  the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth
  token exchange, and that one sends no AbortSignal of its own, so it
  keeps undici's five minutes rather than inheriting a model setting.
- The derived value is floored: the schema accepts any positive number
  and undici rejects a fractional headersTimeout.
2026-09-06 16:11:52 +02:00
Maurice 1b71b8633d fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken".

The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.

The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.

The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.

The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.

What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.

Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.

Closes #2228
2026-09-06 16:11:52 +02:00
Maurice d77f1c1758 fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.

The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.

Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.

Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.

Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.

Closes #2227
2026-09-06 16:11:52 +02:00
Maurice e09c0688ae fix(collab): notes show and keep their website on the phone
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.

Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.

The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.

The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.

Closes #2222
2026-09-06 16:11:52 +02:00
Maurice 0e66ae72ca fix(budget): an expense with no payer creates no debt
An expense saved with "No one paid yet" was still charging its split
members in calculateSettlement while crediting nobody, so the ledger
drifted off zero by the unpaid total. The greedy simplifier that turns
balances into payments has no memory of which expense produced which
debt, so it handed that phantom amount to whoever happened to be a
creditor from an unrelated expense. In the report, two people each owed
about 80 to someone who never paid that bill. Booking every payment it
offered then left one member permanently short, next to an "everyone
square" line.

Both cost panels already promise the opposite: they label such a row
"Unfinished, total only, not settled yet" and count it into the
Outstanding amount card instead. The server now agrees with them.

The guard deliberately does not also require a non-zero total, unlike the
panels' label predicate. PUT /budget/:id/payers with an empty array zeroes
total_price without re-applying it, which would otherwise leave a custom
split debiting its members against no credit at all.

The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned
the old arithmetic through a deliberately payer-less 9 000 RUB item. The
wiki said the old behaviour was intended, so it is updated too.

Closes #2225
2026-09-06 16:11:52 +02:00
jubnl 61bef334d5 feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning

A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.

The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.

Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).

* refactor(plugins): one useRangeBypass hook behind both admin shells

SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.

* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule

The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.

* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy

Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.

Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
2026-09-06 16:11:52 +02:00
jubnl fa406b708f plugin-sdk-v1.7.0 2026-09-03 17:30:31 +02:00
github-actions[bot] 09ce5cb733 chore: bump version to 4.2.0 [skip ci] 2026-09-03 14:24:43 +00:00
401 changed files with 1100 additions and 32592 deletions
+1 -2
View File
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Configurable health checks (liveness/readiness probes) on `/api/health`
- Health checks on `/api/health`
## Helm Repository
@@ -46,7 +46,6 @@ See `values.yaml` for more options.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.2.0
version: 4.2.1
description: Minimal Helm chart for TREK app
appVersion: "4.2.0"
appVersion: "4.2.1"
+10 -6
View File
@@ -80,14 +80,18 @@ spec:
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with (default dict .Values.probes).liveness }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).readiness }}
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
{{- if .Values.persistence.enabled }}
-19
View File
@@ -134,25 +134,6 @@ persistence:
existingClaim: ""
annotations: {}
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
# httpGet handler. Switching handler means clearing the old one in the same override
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
probes:
liveness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readiness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.2.0",
"version": "4.2.1",
"private": true,
"type": "module",
"scripts": {
+2 -3
View File
@@ -283,7 +283,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -300,7 +300,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; 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; 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; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -314,7 +314,6 @@ export default function App() {
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
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
+6 -202
View File
@@ -1,8 +1,6 @@
import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId'
import {
weatherResultSchema, type WeatherResult,
@@ -61,12 +59,6 @@ import {
type PluginActionDescriptor,
type PluginActionResult,
type PluginInstallRequest,
RoadtripDayTrack,
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
RoadtripViaReanchorRequest,
RoadtripViaUpdateRequest,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
@@ -472,15 +464,8 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
},
// A longer timeout than the shared 8 s, like the other routes here that wait
// on somebody else's service. A directions link whose stops are only named
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
// eight stops needs about ten seconds. Giving up at eight left the server
// finishing the import and writing the places while the browser reported a
// failure, and a retry then spent the whole geocoding budget again only to
// have the dedupe skip every stop.
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
@@ -661,18 +646,12 @@ export const adminApi = {
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -1054,134 +1033,11 @@ export const memoriesApi = {
}).then(r => r.data),
}
/**
* Place search that still answers with no network.
*
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
* is the one place it is read from, so all ten call sites got the offline path
* without any of them learning about it. `source` says 'offline-cache' rather
* than pretending to be the index — several screens show it, and a stale answer
* that claims to be live is worse than one that says what it is.
*
* Only network-level failures fall through. A 4xx or 5xx means the server did
* answer and the caller has to see it.
*/
/**
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
*
* Separate because the shape is different: this one is keyed by id rather than
* by a query, and a miss has to stay a miss — a place the cache does not hold
* must fail the way it did before, not answer with somebody else's record.
*/
/**
* The rejection both search surfaces already know to swallow.
*
* They filter on the axios cancel code and on the DOMException name, so an
* abort from the cache path has to look like one or it surfaces as a toast.
*/
function abortedError(): Error & { code: string } {
const err = new Error('canceled') as Error & { code: string }
err.name = 'CanceledError'
err.code = 'ERR_CANCELED'
return err
}
async function withCachedPlace(
placeId: string,
online: () => Promise<{ place: Record<string, unknown> | null }>,
): Promise<{ place: Record<string, unknown> | null }> {
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const hit = await getCachedPlace(placeId)
return hit ? { place: cachedToPlaceRecord(hit) } : null
}
if (isEffectivelyOffline()) {
const cached = await fromCache()
if (cached) return cached
return online()
}
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached) throw err
return cached
}
}
async function withCachedPlaces<T>(
query: string,
shape: (places: Record<string, unknown>[]) => T,
online: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const fromCache = async (): Promise<Record<string, unknown>[]> => {
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
// The keystroke this answers may already be two keystrokes old. Both search
// surfaces order their suggestions by aborting the previous request and
// discarding the rejection — there is no request counter — so an offline
// answer that resolves regardless of the signal can overwrite a newer list.
// The two cache reads are also very unequal: a prefix hit comes off the
// index, the substring pass walks every cached place of every trip.
if (signal?.aborted) throw abortedError()
return found
}
if (isEffectivelyOffline()) return shape(await fromCache())
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached.length) throw err
return shape(cached)
}
}
export const mapsApi = {
/**
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
* is the difference between finding the temple the user stands next to and
* one 400km away. The route has always accepted it; nothing passed it.
*
* It also decides whether the index answers at all: a common single word
* without coordinates is refused upstream as too expensive, and the search
* then falls back to Nominatim alone.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
withCachedPlaces(
input,
(places) => ({
suggestions: places.map((p) => ({
placeId: String(p.osm_id),
mainText: String(p.name),
secondaryText: String(p.address || ''),
})),
source: 'offline-cache',
}),
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
signal,
),
// Answered from the cache when the network is not there, for the ids the
// offline suggestion list hands out. Without it, picking an offline
// suggestion failed here and the callers fell back to searching for
// "name, address" — which the cache matches on the folded NAME alone, so it
// found nothing and the user got an error toast for a place that was sitting
// in the cache all along.
details: (placeId: string, lang?: string, sessionToken?: string) =>
withCachedPlace(placeId, () =>
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
// Pictures and a description for a place that is being looked at but not yet
// saved. Fans out to several providers server-side, so it takes a signal and
// the caller is expected to abort it when the selection changes, and a longer
@@ -1201,60 +1057,10 @@ export const mapsApi = {
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
/**
* Every place in a box, for the offline cache. One call per trip area, not
* per keystroke, so the timeout is generous where the search ones are short.
*/
area: (
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
limit?: number,
signal?: AbortSignal,
) =>
apiClient
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
.then(
(r) =>
r.data as {
results: Record<string, unknown>[]
truncated: boolean
unavailable?: boolean
},
),
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
/**
* Road-trip via points (#1797): the places a day's drive is routed through without
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
* you go.
*/
export const roadtripApi = {
/** Every via of the trip, so all days can be routed without a request per day. */
listVias: (tripId: number | string) =>
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
/**
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
* line produces dozens of them, and one request each would re-route the whole trip once
* per point at better than a second apart.
*/
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
/**
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
* per via: the anchors are only correct as a set.
*/
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
}
export const airportsApi = {
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
@@ -1424,13 +1230,11 @@ export const shareApi = {
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
// Public transit routing (#1065) — proxied through the server, which picks the
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
// backend as the languageCode, so station names come back in the user's script.
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
export const transitApi = {
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
apiClient.get('/transit/plan', { params }).then(r => r.data),
}
-115
View File
@@ -1,115 +0,0 @@
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 { recordPlacePick } from './placeShadow'
import type { PlaceShadowPickRequest } from '@trek/shared'
/**
* The point of this module is that it never gets in the way: it must not fire
* when the instance has the log switched off, must not throw when the request
* fails, and must not send coordinates finer than the ranking needs.
*/
const PICK: PlaceShadowPickRequest = {
query: ' losteria rostock ',
lang: 'de',
biasLat: 54.088712,
biasLng: 12.140493,
source: 'search:nominatim',
liveRank: 0,
liveCount: 8,
pickedName: "L'Osteria",
pickedLat: 54.0891234,
pickedLng: 12.1372987,
pickedPlaceId: 'osm:node/1',
}
/** Resolves with the body the module posted, or null if it never posted. */
function capture(status = 200) {
let seen: unknown = null
let resolve: (v: unknown) => void = () => {}
const posted = new Promise<unknown>(r => { resolve = r })
server.use(
http.post('/api/place-shadow/pick', async ({ request }) => {
seen = await request.json()
resolve(seen)
return status === 200
? HttpResponse.json({ recorded: true })
: HttpResponse.json({ error: 'nope' }, { status })
}),
)
return { posted, seen: () => seen }
}
beforeEach(() => {
useAuthStore.setState({ placeShadowEnabled: true })
})
describe('recordPlacePick', () => {
it('sends nothing at all when the instance has the log switched off', async () => {
useAuthStore.setState({ placeShadowEnabled: false })
const cap = capture()
recordPlacePick(PICK)
await new Promise(r => setTimeout(r, 20))
expect(cap.seen()).toBeNull()
})
it('trims the query and rounds every coordinate to three decimals', async () => {
const cap = capture()
recordPlacePick(PICK)
expect(await cap.posted).toMatchObject({
query: 'losteria rostock',
pickedLat: 54.089,
pickedLng: 12.137,
biasLat: 54.089,
biasLng: 12.14,
})
})
it('leaves an absent bias absent rather than sending a zero', async () => {
const cap = capture()
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
const body = (await cap.posted) as Record<string, unknown>
expect(body.biasLat).toBeUndefined()
expect(body.biasLng).toBeUndefined()
})
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
const cap = capture()
recordPlacePick({ ...PICK, query: ' ' })
recordPlacePick({ ...PICK, pickedName: '' })
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
await new Promise(r => setTimeout(r, 20))
expect(cap.seen()).toBeNull()
})
it('does not record a pick made out of the offline cache', async () => {
// Such a row describes the local cache's ordering, not a provider's, and
// the two figures this corpus exists for — how often the live answer was
// first, and how often it was in the top five — are averaged over every
// row. Mixing these in moves exactly the number a candidate index is later
// judged against. Not merely an offline concern either: offline is a
// setting, so the network is usually reachable and the POST would land.
let posted = false
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
await new Promise(r => setTimeout(r, 20))
expect(posted).toBe(false)
})
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
const cap = capture(500)
const onUnhandled = vi.fn()
process.on('unhandledRejection', onUnhandled)
expect(() => recordPlacePick(PICK)).not.toThrow()
await cap.posted
await new Promise(r => setTimeout(r, 20))
process.off('unhandledRejection', onUnhandled)
expect(onUnhandled).not.toHaveBeenCalled()
})
})
-51
View File
@@ -1,51 +0,0 @@
import type { PlaceShadowPickRequest } from '@trek/shared'
import { apiClient } from './client'
import { useAuthStore } from '../store/authStore'
/**
* Records which search result the user picked, for the local shadow corpus.
*
* Fire and forget in the strongest sense: it never throws, never blocks the
* pick it is describing, and does nothing at all unless the instance switched
* the log on. Adding a place must not get slower or more fragile because
* somebody is collecting a measurement.
*
* The store flag is checked first so a switched-off instance makes no request
* at all; the server checks it again, because a client is not a permission.
*/
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
function coarse(value: number): number {
return Math.round(value * 1000) / 1000
}
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
if (!useAuthStore.getState().placeShadowEnabled) return
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
if (pick.liveRank >= pick.liveCount) return
// A pick out of the offline cache describes the cache's ordering, not a
// provider's, and the two numbers this corpus exists for — how often the live
// answer was first, and how often it was in the top five — are averaged over
// every row. Mixing local rows in would move exactly the figure a candidate
// index is later judged against.
//
// Not merely an offline concern: the offline switch is a setting, so the
// network is usually reachable and the POST would go through.
if (pick.source.endsWith('offline-cache')) return
const body: PlaceShadowPickRequest = {
...pick,
query: pick.query.trim().slice(0, 200),
pickedName: pick.pickedName.slice(0, 300),
pickedLat: coarse(pick.pickedLat),
pickedLng: coarse(pick.pickedLng),
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
}
// The contract requires both; an unnamed result is not worth a row anyway.
if (!body.query || !body.pickedName) return
void apiClient.post('/place-shadow/pick', body).catch(() => {
// Deliberately silent. The user was adding a place, not running a study.
})
}
@@ -43,7 +43,6 @@ type Defaults = {
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
routing_base_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -120,7 +119,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -130,7 +128,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setRoutingBase(data.routing_base_url || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -153,7 +150,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'routing_base_url') setRoutingBase('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -369,32 +365,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
{/* Instance configuration, not a taste, which is why it sits here rather
than in a user's own Map settings. The origin has to appear in the CSP
`connect-src` the server emits at boot, and that list is built from
this default — a value on a personal settings row is read by the route
calculator and then refused by the browser, so the whole app stops
routing with no error it could report. The write route refuses one
from a non-admin for the same reason. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.routingBase')}
<ResetButton field="routing_base_url" />
</label>
<input
type="text"
value={routingBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
onBlur={() => save({ routing_base_url: routingBase.trim() })}
placeholder="https://osrm.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
+24 -225
View File
@@ -3,34 +3,7 @@ import DOM from 'react-dom'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
/**
* The drive-time pill for one offered route.
*
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
* road, and it is the same shape the GL renderers build by hand — one look across all
* three maps.
*/
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
const second = note
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
: ''
return L.divIcon({
className: '',
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
// one piece of TREK chrome on the map that is not in the app's typeface.
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
})
}
import { makeMarkerDraggable } from './markerDrag'
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
@@ -103,21 +76,9 @@ function formatViaDwell(seconds: number): string {
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
// A stop that interrupts the drive is drawn as its own small disc, before the photo
// branch below ever gets a look at it — the brand logo a fuel search comes back with
// is exactly what this replaces. No number badge either, for the same reason the rail
// gives it none: it is part of the drive, not one of the day's stops.
const service = serviceMarkerHtml(place.stop_type, isSelected)
if (service) {
const outer = serviceMarkerOuter(isSelected)
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
iconCache.set(cacheKey, icon)
return icon
}
const size = isSelected ? 44 : 36
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
@@ -198,13 +159,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>()
function createPoiIcon(category: string, brandWikidata?: string | null) {
// One flat disc in the category's colour with its icon, and never the chain's logo.
// The brands turned a corridor full of petrol stations into a row of advertisements,
// they were unreadable at pin size, and a brand with no logo on file fell back to a
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
// the signature because the callers still have it; it simply no longer changes anything.
void brandWikidata
function createPoiIcon(category: string) {
const cached = poiIconCache.get(category)
if (cached) return cached
const cat = POI_CATEGORY_BY_KEY[category]
@@ -212,7 +167,7 @@ function createPoiIcon(category: string, brandWikidata?: string | null) {
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const icon = L.divIcon({
className: '',
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
@@ -238,41 +193,6 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area.
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
@@ -357,17 +277,9 @@ interface BoundsControllerProps {
paddingOpts: L.FitBoundsOptions
/** The map was built already framed on these places, so the opening fit has nothing to do. */
framedOnMount?: boolean
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
@@ -424,15 +336,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
if (!focusPoints?.length) return
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints)
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -635,18 +538,6 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
roadtripVias,
onMoveVia,
onRemoveVia,
alternativeRoutes,
focusPoints,
clusterLoosely = false,
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
}: any) {
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
@@ -656,21 +547,13 @@ export const MapView = memo(function MapView({
<Marker
key={`poi-${poi.osm_id}`}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
icon={createPoiIcon(poi.category)}
zIndexOffset={500}
eventHandlers={{
click: () => onPoiClick?.(poi),
// Wired on add rather than in a layout effect: Leaflet builds the icon element
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
eventHandlers={{ click: () => onPoiClick?.(poi) }}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
)), [pois, onPoiClick])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
@@ -989,127 +872,43 @@ 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} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
<ViewportController onViewportChange={onViewportChange} />
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
zoomed right out, and a day's stops merging into one dot is exactly the shape
it is being looked at for. Rendered outside the group rather than with the
clustering disabled by option, so nothing is left to a plugin's own idea of
what "disabled" means. */}
{clusterLoosely ? markers : (
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
)}
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu
// underneath the new via.
e.originalEvent.stopPropagation()
onRouteClick(e.latlng.lat, e.latlng.lng)
},
} : undefined}
/>,
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
{/* The last bit to a place the road does not reach.
Dashed and thin, over the route rather than under it, because it is the one
piece of the line that is not driving: the router snapped the stop to the
nearest road and the drive really ends there. Same blue as the route, so it
reads as the end of that route and not as a second one. */}
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
))}
{/* The offered ways of driving one leg, over the route they replace. Clicking one
takes it, which is the same choice the bar above the map offers. */}
{/* Every option in blue over a white casing, after Apple Maps: they are all real
roads, so the difference is emphasis, not category. Grey was the first attempt
and it disappeared on Positron, which is almost entirely greys. */}
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
const active = activeAlternative === alt.index
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
opacity: active ? 1 : 0.9,
lineCap: 'round',
lineJoin: 'round',
}}
eventHandlers={onChooseAlternative ? {
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
mouseover: () => onHighlightAlternative?.(alt.index),
mouseout: () => onHighlightAlternative?.(null),
} : undefined}
/>,
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
click: () => onChooseAlternative(alt.index),
mouseover: () => onHighlightAlternative?.(alt.index),
mouseout: () => onHighlightAlternative?.(null),
} : undefined}
/>,
]
})}
{/* The handles that shape the drive. After the route so they sit on top of it. */}
{roadtripVias ? (
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
@@ -1855,104 +1855,4 @@ describe('MapViewGL', () => {
expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function))
expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
})
it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => {
// Same drift the planned-place pins had: a library Marker repositions on every
// `move` event — one per pointer sample — while the canvas draws once a frame, so
// the suggestions swam over the map during a drag and snapped back on release.
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
pois={[{ osm_id: 'node:1', name: 'Aral', lat: 48.1, lng: 2.1, category: 'fuel', source: 'openstreetmap' } as never]}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
expect(layer.children.length).toBe(1)
const pin = layer.firstElementChild as HTMLElement
expect(pin.style.transform).toContain('translate(100px, 80px)')
glMap.project.mockReturnValue({ x: 140, y: 60 })
act(() => { mapHandler('render')() })
expect(pin.style.transform).toContain('translate(140px, 60px)')
})
// A via handle is a DOM element, and a pointerdown belongs to the element it happened
// on. Rebuilding the handles on an unrelated render pulled that element out from under
// the pointer, so a freshly placed via lost its first drag — the re-route it triggered
// landed about a second later, which is exactly when somebody reaches for it.
it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
const { rerender } = render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [stored] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild
expect(handle).toBeTruthy()
// The same via in a fresh object, with a fresh callback: what every render of the
// planner hands down while the day re-routes.
rerender(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [{ ...stored }] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
expect(layer.firstElementChild).toBe(handle)
})
it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild
rerender(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [{ ...stored, lat: 48.9 }] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
// The other half of the contract: holding the element still must not mean holding it
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
})
+73 -613
View File
@@ -1,10 +1,5 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import type { RoadtripVia } from '@trek/shared'
import { useStableVias } from './viaMarkerState'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import { makeMarkerDraggable } from './markerDrag'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
@@ -86,8 +81,6 @@ interface RouteSegment {
// mouseleave never fires" case (#1404).
const NO_PLACES: Place[] = []
const NO_ROUTE_VIAS: RouteVia[] = []
/** Stable empty default: a fresh array each render would refire the spur effect. */
const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = []
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
const NO_DAY_ORDER: Record<number, number[] | null> = {}
const NO_RESERVATIONS: Reservation[] = []
@@ -104,8 +97,6 @@ interface Props {
tripId?: number | string
// Charging stops / rest areas a plugin route places on the drawn day route.
routeVias?: RouteVia[]
/** The dashed last bit to a place the road network does not reach. */
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
route?: [number, number][][] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
@@ -130,39 +121,6 @@ interface Props {
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
/**
* A corridor hit dropped somewhere on the map, with the coordinate it landed on.
* The caller decides whether that point is near enough to the drive to mean anything.
*/
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void
/** A click on the drawn route, for putting a via point there (#1797). */
onRouteClick?: (lat: number, lng: number) => void
/** The ways of driving one leg, drawn while the picker is open. */
alternativeRoutes?: AlternativeOverlay[]
/** Which option is being considered, so it can be lit up in its own colour. */
activeAlternative?: number | null
onChooseAlternative?: (index: number) => void
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/**
* Let markers stay apart longer than usual.
*
* A road trip is read along a line: two stops fifty kilometres apart on the same
* motorway are the shape of the day, and merging them into one dot hides it.
*/
clusterLoosely?: boolean
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
onRemoveVia?: (dayId: number, id: number) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
glProvider?: GlMapProvider
/**
@@ -176,76 +134,7 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster, outside road trip mode. */
const CLUSTER_RADIUS = 30
const CLUSTER_MAX_ZOOM = 10
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
// No number badge either, for the same reason the rail gives it none.
const service = serviceMarkerHtml(place.stop_type, selected)
if (service) {
const outer = serviceMarkerOuter(selected)
const wrap = document.createElement('div')
wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center`
wrap.innerHTML = service
return wrap
}
const size = selected ? 44 : 36
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
@@ -467,20 +356,6 @@ function makePlacePin(map: any, layer: HTMLElement, el: HTMLElement, lng: number
}
}
/**
* Puts an element on the map as a pin.
*
* `layer` is only ever set under MapLibre (that is where it gets created), so passing it
* along is the whole provider decision: with a layer we position the element ourselves on
* the map's render clock, without one the library's own Marker does it.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function attachPin(map: any, gl: any, layer: HTMLElement | null, el: HTMLElement, lng: number, lat: number): PlacePin {
return layer
? makePlacePin(map, layer, el, lng, lat)
: libraryPin(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map))
}
// Tone dot for a plugin marker — visual twin of MapPluginMarkers' divIcon.
function createPluginMarkerElement(tone: PluginMapMarker['tone']): HTMLDivElement {
const color = PLUGIN_TONE_COLORS[tone] ?? PLUGIN_TONE_COLORS.default
@@ -520,37 +395,21 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement {
}
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
// A chain shows its logo instead of the category icon: on a corridor full of petrol
// stations the brand is what the eye is looking for, and the server proxies it so the
// browser never asks Wikimedia which ones are on screen.
function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement {
function createPoiMarkerElement(category: string): HTMLDivElement {
const cat = POI_CATEGORY_BY_KEY[category]
const color = cat?.color || '#6b7280'
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const el = document.createElement('div')
el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;'
el.innerHTML = `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;overflow:hidden;">${svg}${brandLogoMarkup(brandWikidata)}</div>`
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
return el
}
/**
* Kept as the one place that decides a POI pin carries no brand mark: nothing.
*
* The logos turned a corridor full of petrol stations into a row of advertisements, were
* unreadable at pin size, and a brand with no logo on file fell back to a different
* picture entirely — so no two pins looked alike. A flat disc in the category's colour
* with its icon is what the rail, the corridor list and the map all show now.
*/
export function brandLogoMarkup(_brandWikidata?: string | null): string {
return ''
}
export function MapViewGL({
places = NO_PLACES,
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
accessLines = NO_ACCESS_LINES,
route = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
@@ -561,8 +420,6 @@ export function MapViewGL({
center = DEFAULT_MAP_CENTER,
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
focusPoints,
clusterLoosely = false,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -577,15 +434,6 @@ export function MapViewGL({
onReservationClick,
pois = NO_POIS,
onPoiClick,
onPoiDropOnRoute,
onRouteClick,
alternativeRoutes,
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
roadtripVias,
onMoveVia,
onRemoveVia,
onViewportChange,
glProvider = 'mapbox-gl',
gl,
@@ -632,336 +480,31 @@ export function MapViewGL({
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
const pinLayerRef = useRef<HTMLDivElement | null>(null)
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
}, [])
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef<PlacePin[]>([])
/** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */
const poiCleanupRef = useRef<(() => void)[]>([])
const onPoiDropRef = useRef(onPoiDropOnRoute)
onPoiDropRef.current = onPoiDropOnRoute
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poiMarkersRef = useRef<any[]>([])
// Plugin map contributions — data fetched per trip, elements owned imperatively
// like the POI markers so they survive the React render cycle.
const [pluginMarkers, setPluginMarkers] = useState<PluginMapMarker[]>([])
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
const pluginMarkersRef = useRef<PlacePin[]>([])
const routeViaMarkersRef = useRef<PlacePin[]>([])
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
const viaPinsRef = useRef<PlacePin[]>([])
/** The drive-time pills on the offered routes; same treatment. */
const altLabelsRef = useRef<PlacePin[]>([])
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
// in the same frame the canvas draws, or the ones left out swim against the ones in.
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
poiMarkersRef.current.forEach(pin => pin.reposition())
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
viaPinsRef.current.forEach(pin => pin.reposition())
altLabelsRef.current.forEach(pin => pin.reposition())
}, [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginMarkersRef = useRef<any[]>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const routeViaMarkersRef = useRef<any[]>([])
// Single reusable hover popup for POI markers. Planned places use the
// cursor-following React tooltip below so they match the Leaflet map.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
/**
* The via handles.
*
* Built as plain elements and attached with the same `attachPin` the place pins use,
* rather than the library's own marker: under MapLibre those float above the map during
* a drag instead of sticking to the ground (the reason `attachPin` exists at all). The
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
*/
const viaCleanupRef = useRef<(() => void)[]>([])
/**
* The list only changes when a via does, and the callbacks are read through a ref.
*
* Both matter for the same reason: this effect destroys every handle and builds new DOM
* elements. It used to re-run on every render of the planner — the vias arrived as a
* fresh object each time and `onMoveVia` as a fresh closure — so the render that landed
* when a freshly placed via finished re-routing pulled the element out from under the
* pointer, and the first drag went nowhere.
*/
const vias = useStableVias(roadtripVias)
const viaHandlersRef = useRef({ onMoveVia, onRemoveVia })
viaHandlersRef.current = { onMoveVia, onRemoveVia }
const viasDraggable = !!onMoveVia
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
viaCleanupRef.current.forEach(off => off())
viaCleanupRef.current = []
viaPinsRef.current.forEach(p => p.remove())
viaPinsRef.current = []
for (const via of vias) {
const el = document.createElement('span')
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
viaPinsRef.current.push(pin)
if (!viasDraggable) continue
let dragging = false
// The map starts panning on mousedown/touchstart anywhere on its surface, and a pin
// sits on that surface. Stopping only pointerdown left both running at once: the
// handle moved AND the map slid out from under it.
const swallow = (e: Event) => e.stopPropagation()
// Where the gesture began, so a click can be told from a drag. Without it
// every pointerup committed a move to wherever inside the 17px handle the
// pointer happened to be: at a low zoom that is kilometres, and the write
// re-routes the whole trip. Leaflet gets this for free from `dragend`,
// which is why the two renderers behaved differently for one gesture.
let startedAt: { x: number; y: number } | null = null
const MOVED_ENOUGH = 4
const onDown = (e: PointerEvent) => {
// Primary button only. Right-clicking ran onDown, then contextmenu ->
// remove AND pointerup -> move, so one gesture sent two writes; on the
// platforms where contextmenu comes first, the move landed on an id that
// no longer existed and reported a failure for a deletion that worked.
if (e.button !== 0) return
e.stopPropagation()
e.preventDefault()
dragging = true
startedAt = { x: e.clientX, y: e.clientY }
el.setPointerCapture(e.pointerId)
el.style.cursor = 'grabbing'
}
const onMove = (e: PointerEvent) => {
if (!dragging) return
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
pin.setLngLat([at.lng, at.lat])
}
const onUp = (e: PointerEvent) => {
if (!dragging) return
dragging = false
el.style.cursor = 'grab'
const from = startedAt
startedAt = null
const travelled = from ? Math.hypot(e.clientX - from.x, e.clientY - from.y) : 0
if (travelled < MOVED_ENOUGH) {
// A click, not a drag. Put the pin back where the via actually is: the
// move handler has been following the pointer across the handle.
pin.setLngLat([via.lng, via.lat])
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
// which is exactly what a delete does. Committing a position there wrote a
// move for a via that was being removed.
const onCancel = () => {
if (!dragging) return
dragging = false
startedAt = null
el.style.cursor = 'grab'
pin.setLngLat([via.lng, via.lat])
}
const onContext = (e: MouseEvent) => {
e.preventDefault()
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
}
el.addEventListener('pointerdown', onDown)
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
el.addEventListener('pointercancel', onCancel)
el.addEventListener('contextmenu', onContext)
el.addEventListener('mousedown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('dblclick', swallow)
viaCleanupRef.current.push(() => {
el.removeEventListener('pointerdown', onDown)
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.removeEventListener('pointercancel', onCancel)
el.removeEventListener('contextmenu', onContext)
el.removeEventListener('mousedown', swallow)
el.removeEventListener('touchstart', swallow)
el.removeEventListener('dblclick', swallow)
})
}
}, [vias, viasDraggable, mapReady, glProvider])
/**
* The offered routes, drawn under the current one.
*
* Grey and dashed rather than a set of bright lines: at any moment one of them IS the
* route, drawn in blue on top, and three more solid colours next to it would read as
* four equal roads. Hovering an option in the bar lights that one up in its own colour,
* which is the only moment a second colour helps.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const source = map.getSource?.('route-alternatives')
if (!source?.setData) return
source.setData({
type: 'FeatureCollection',
features: (alternativeRoutes ?? []).map((alt, i) => ({
type: 'Feature',
properties: { index: i, active: activeAlternative === i, color: alt.color },
geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) },
})),
})
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
/**
* The drive time on each offered route, the way Apple Maps labels them.
*
* Hand-positioned pins rather than a symbol layer: a symbol would need the text in the
* style's own font stack, which differs between the bundled MapLibre styles and Mapbox,
* and the pill has a shape a text layer cannot draw. They join the render clock like
* every other pin here, so they stay on their road while the map moves.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
altLabelsRef.current.forEach(p => p.remove())
altLabelsRef.current = []
for (const alt of alternativeRoutes ?? []) {
const el = document.createElement('button')
el.type = 'button'
const active = activeAlternative === alt.index
el.style.cssText = [
// A rounded rectangle rather than a pill, and roomy enough for a second line —
// the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one
// label instead of two stacked chips.
'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap',
`background:${alt.labelBg}`,
`color:${ALT_LABEL_TEXT}`,
'border:none;border-radius:11px',
// A <button> does not inherit the page's font, so without this the label reads in
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
'font-family:var(--font-system)',
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
'cursor:pointer;pointer-events:auto',
// The pin is positioned by writing `transform` every frame. Any inherited
// transition on it makes the label chase the map instead of sticking to the road,
// and `transform` is exactly what a button picks up from the app's own styles.
'transition:none !important;animation:none !important',
'will-change:transform;backface-visibility:hidden',
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
].filter(Boolean).join(';')
const time = document.createElement('span')
time.textContent = alt.label
el.appendChild(time)
if (alt.note) {
const note = document.createElement('span')
note.textContent = alt.note
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
el.appendChild(note)
}
// Same reason as the via handles: without this the map pans as the label is pressed.
el.addEventListener('mousedown', e => e.stopPropagation())
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
}
return () => {
altLabelsRef.current.forEach(p => p.remove())
altLabelsRef.current = []
}
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onChooseAlternative) return
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
const index = e.features?.[0]?.properties?.index
if (typeof index === 'number') onChooseAlternative(index)
}
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
const leave = () => { map.getCanvas().style.cursor = '' }
if (!map.getLayer?.('route-alt-hit')) return
map.on('click', 'route-alt-hit', onClick)
map.on('mouseenter', 'route-alt-hit', enter)
map.on('mouseleave', 'route-alt-hit', leave)
return () => {
map.off('click', 'route-alt-hit', onClick)
map.off('mouseenter', 'route-alt-hit', enter)
map.off('mouseleave', 'route-alt-hit', leave)
}
}, [mapReady, onChooseAlternative])
/**
* A click on the route line, which in a GL renderer is a layer rather than an element.
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
* click, so the handler can keep the add-place menu from opening underneath the via.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onRouteClick) return
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
e.preventDefault?.()
// Layer handlers are independent: MapLibre evaluates each registration
// against the same click, so a point that hits both the offered-route band
// and the current-route band fires both. Every alternative starts and ends
// at the same two stops, and when the leg already carries vias the current
// road is itself one of the offers, so the overlap is the whole leg. One
// click then dropped a via AND chose a different road, two writes and two
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
for (const layer of ['trip-route-hit']) {
if (!map.getLayer?.(layer)) continue
map.on('click', layer, onClick)
map.on('mouseenter', layer, enter)
map.on('mouseleave', layer, leave)
}
return () => {
for (const layer of ['trip-route-hit']) {
map.off('click', layer, onClick)
map.off('mouseenter', layer, enter)
map.off('mouseleave', layer, leave)
}
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const onMapReadyRef = useRef(onMapReady)
@@ -973,13 +516,6 @@ export function MapViewGL({
onClickRefs.current.context = onMapContextMenu
const hoverDisabledRef = useRef(hoverDisabled)
hoverDisabledRef.current = hoverDisabled
// Read inside the map's own click handler, which is registered once at setup.
const onRouteClickRef = useRef(onRouteClick)
onRouteClickRef.current = onRouteClick
const onChooseAlternativeRef = useRef(onChooseAlternative)
onChooseAlternativeRef.current = onChooseAlternative
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
onHighlightAlternativeRef.current = onHighlightAlternative
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
// day plan the marker would be dropped on is not on screen on a phone anyway.
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
@@ -1063,49 +599,6 @@ export function MapViewGL({
if (glStyle === MAPBOX_DEFAULT_STYLE) {
try { map.setTerrain(null) } catch { /* noop */ }
}
// The ways of driving one leg, offered while the picker is open (#1797). Added
// BEFORE the route source so its layers sit underneath: the current route stays the
// brightest thing on the map, and the options read as what they are — suggestions.
if (!map.getSource('route-alternatives')) {
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// A wide invisible band per option, so a pointer can pick one without hitting
// the 3px line exactly.
map.addLayer({
id: 'route-alt-hit',
type: 'line',
source: 'route-alternatives',
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// A white casing under the option lines, the same trick the main route uses.
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
// grey dashed line on it is invisible. The casing gives every option an edge, so
// it reads on a pale map, a dark one and a satellite tile alike.
map.addLayer({
id: 'route-alt-casing',
type: 'line',
source: 'route-alternatives',
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'route-alt-line',
type: 'line',
source: 'route-alternatives',
// Dark and dashed by default so it stays subordinate to the blue route it is an
// alternative to; the one being considered takes its own colour and thickens.
// Every option in blue, after Apple Maps: they are all real roads, so the
// difference between them is emphasis rather than category. The one being
// considered thickens; nothing turns grey, because grey vanishes on Positron.
paint: {
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// initial route source — kept around so updates can setData() cheaply
if (!map.getSource('trip-route')) {
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
@@ -1118,16 +611,6 @@ export function MapViewGL({
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// An invisible band over the route, purely to be clicked. The drawn line is 8px
// and a pointer is not that accurate, so aiming at the road was most of why
// putting a via there felt like it did not work.
map.addLayer({
id: 'trip-route-hit',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-route-line',
type: 'line',
@@ -1136,21 +619,6 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// The last bit to a place the road does not reach (#1797). Added after the route
// so it draws over it: it is the one piece of the line that is not driving, and it
// has to be readable exactly where it meets the road it leaves. Same blue, because
// it is the end of that route rather than a second one; dashed short and thin,
// which is how a map says "on foot from here" without a legend.
if (!map.getSource('trip-access')) {
map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({
id: 'trip-access-line',
type: 'line',
source: 'trip-access',
paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
layout: { 'line-cap': 'round' },
})
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
@@ -1213,7 +681,53 @@ export function MapViewGL({
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
}
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
addPlaceClusterLayers(map)
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: 30,
clusterMaxZoom: 10,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zoomToCluster = (e: any) => {
const features = typeof map.queryRenderedFeatures === 'function'
@@ -1331,15 +845,6 @@ export function MapViewGL({
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
) return
// And for the drive itself while it can be reshaped: that click just put a via
// there, and it must not also drop a place on top of it (#1797).
if (
onRouteClickRef.current
&& typeof map.getLayer === 'function'
&& map.getLayer('trip-route-hit')
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -1663,7 +1168,12 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift.
markersRef.current.set(place.id, attachPin(map, gl, pinLayerRef.current, el, place.lng, place.lat))
const pin = isMapLibre && pinLayerRef.current
? makePlacePin(map, pinLayerRef.current, el, place.lng, place.lat)
: libraryPin(new gl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat])
.addTo(map))
markersRef.current.set(place.id, pin)
})
}
@@ -1675,18 +1185,6 @@ export function MapViewGL({
return
}
// Road trip mode does not cluster at all: the whole drive has to stay readable
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
// is being looked at for. The source is emptied rather than reconfigured — the
// cluster options can only be set when a GeoJSON source is created, and dropping and
// re-adding a source under three layers to change a radius is not worth it when the
// markers are drawn from `places` anyway.
if (clusterLoosely) {
source.setData({ type: 'FeatureCollection', features: [] })
reconcileMarkers(validPlaces)
return
}
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
@@ -1725,7 +1223,7 @@ export function MapViewGL({
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
}
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
@@ -1735,19 +1233,15 @@ export function MapViewGL({
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
const el = createPoiMarkerElement(poi.category)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
}
}, [pois, mapReady, glProvider])
@@ -1793,7 +1287,8 @@ export function MapViewGL({
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
})
}
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([v.lng, v.lat]).addTo(map)
routeViaMarkersRef.current.push(m)
}
}, [routeVias, mapReady, glProvider])
@@ -1811,7 +1306,7 @@ export function MapViewGL({
popupRef.current?.setLngLat([mk.lng, mk.lat]).setDOMContent(buildPluginMarkerPopup(mk)).addTo(map)
})
}
const m = attachPin(map, gl, pinLayerRef.current, el, mk.lng, mk.lat)
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([mk.lng, mk.lat]).addTo(map)
pluginMarkersRef.current.push(m)
}
}, [pluginMarkers, mapReady, glProvider])
@@ -1830,22 +1325,6 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [route, mapReady])
// Update access-spur geojson
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
if (!src) return
src.setData({
type: 'FeatureCollection',
features: (accessLines || []).map(spur => ({
type: 'Feature' as const,
properties: { meters: Math.round(spur.meters) },
geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
})),
})
}, [accessLines, mapReady])
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
// Update GPX geometries
@@ -1973,25 +1452,6 @@ export function MapViewGL({
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
const map = mapRef.current
if (!map || !focusPoints?.length) return
const bounds = new gl.LngLatBounds()
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
// A day fit still waiting on its route must not overwrite this a moment later.
pendingRouteFitRef.current = null
try {
map.fitBounds(bounds, {
padding: paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
const map = mapRef.current
@@ -1,85 +0,0 @@
import React, { useMemo, useRef } from 'react'
import { Marker, Tooltip } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
import type { RoadtripVia } from '@trek/shared'
/**
* The handles that shape a drive.
*
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
* like one would read as a place on the itinerary. Dragging it redraws the route through
* the new spot, which is the whole interaction — the same one a paper map gets when you
* put a finger on the road you actually want to take.
*/
const VIA_ICON = L.divIcon({
className: '',
html: `<span style="
display:block;width:12px;height:12px;border-radius:9999px;
background:#0a84ff;border:2.5px solid #ffffff;
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
"></span>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
})
interface RoadtripViaMarkersProps {
viasByDay: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
onRemoveVia?: (dayId: number, id: number) => void
}
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
export default function RoadtripViaMarkers({
viasByDay, onMoveVia, onRemoveVia,
}: RoadtripViaMarkersProps): React.ReactElement | null {
const { t } = useTranslation()
const vias = useStableVias(viasByDay)
// Read through a ref so the handlers below can stay the same objects across renders.
// Both callbacks are rebuilt on every render of the planner, and react-leaflet compares
// its props by identity: a fresh `eventHandlers` unbinds and rebinds every listener,
// and a fresh `position` array calls `setLatLng` on every marker. Doing either while a
// drag is in flight is how a freshly placed via lost its first drag.
const handlersRef = useRef({ onMoveVia, onRemoveVia })
handlersRef.current = { onMoveVia, onRemoveVia }
const draggable = !!onMoveVia
const markers = useMemo(() => vias.map(via => ({
via,
position: [via.lat, via.lng] as [number, number],
handlers: {
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const at = e.target.getLatLng()
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
},
// Right-click rather than a delete handle: a 12px dot has no room for one,
// and the same gesture removes things elsewhere on the map.
contextmenu: (e: { originalEvent: MouseEvent }) => {
e.originalEvent.preventDefault()
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
},
},
})), [vias])
if (!markers.length) return null
return (
<>
{markers.map(({ via, position, handlers }) => (
<Marker
key={`via-${via.id}`}
position={position}
icon={VIA_ICON}
draggable={draggable}
zIndexOffset={400}
eventHandlers={handlers}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
{t('roadtrip.via.hint')}
</Tooltip>
</Marker>
))}
</>
)
}
+13 -262
View File
@@ -12,11 +12,10 @@ import {
generateCoMapsUrl,
parsePluginProfile,
withHotelBookends,
calculateAlternatives,
} from './RouteCalculator'
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
// demo ignored the profile in the URL, so walking routes followed the road network.
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
const FOSSGIS = {
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
@@ -47,7 +46,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () =>
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
@@ -57,7 +56,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () =>
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
)
)
@@ -67,7 +66,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () =>
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(800, 360))
)
)
@@ -79,7 +78,7 @@ describe('calculateRoute', () => {
const distance = 5000
const osrmDuration = 999
server.use(
http.get(`${FOSSGIS.walking}/:coords`, () =>
http.get(`${OSRM_BASE}/walking/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
)
)
@@ -91,7 +90,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () =>
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({}, { status: 500 })
)
)
@@ -100,7 +99,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () =>
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })
)
)
@@ -109,7 +108,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () =>
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
@@ -129,7 +128,7 @@ describe('calculateSegments', () => {
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () =>
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({
code: 'Ok',
routes: [
@@ -326,7 +325,7 @@ describe('parsePluginProfile', () => {
describe('calculateRoute profiles', () => {
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
server.use(
http.get(`${FOSSGIS.cycling}/:coords`, () =>
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
)
)
@@ -341,12 +340,12 @@ describe('calculateRoute profiles', () => {
describe('calculateSegments failures', () => {
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
})
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
})
})
@@ -614,251 +613,3 @@ describe('generateCoMapsUrl', () => {
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
})
})
describe('a self-hosted routing engine', () => {
afterEach(() => {
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
const useOwnRouter = (base: string) =>
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
useOwnRouter('https://osrm.example.org')
let asked = ''
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).toContain('osrm.example.org')
})
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
useOwnRouter('https://osrm.example.org')
let asked = ''
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
expect(asked).toContain('/route/v1/foot/')
})
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
useOwnRouter('https://osrm.example.org///')
let asked = ''
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).not.toContain('//route')
})
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
useOwnRouter(' ')
let asked = ''
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).toContain('routing.openstreetmap.de')
})
})
describe('calculateAlternatives', () => {
const twoRoutes = {
code: 'Ok',
routes: [
// The direct one, straight down the middle.
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
// A detour that swings well to the east before rejoining.
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
],
}
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
let asked = ''
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
asked = request.url
return HttpResponse.json(twoRoutes)
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(asked).toContain('alternatives=3')
expect(routes).toHaveLength(2)
expect(routes[0].distance).toBe(100000)
})
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
// The first is what the router gives anyway, so it needs no pinning point.
expect(routes[0].divergence).toBeNull()
// The second diverges at its eastern swing — saving that as a via forces this road.
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
})
/** The router's own answer, and whatever it says when a road class is left out. */
const withExclusions = (byExclude: Record<string, unknown>) =>
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
const answer = byExclude[exclude]
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
})
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
// the identical line a second time would be a choice that is not a choice.
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(1)
expect(routes[0].divergence).toBeNull()
})
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
// OSRM answers most long legs with exactly one route, which used to mean the picker
// said there was no other way — while a perfectly good slower road existed.
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(2)
expect(routes[1].avoids).toBe('motorway')
// Pinnable like any other offer: the point where it leaves the direct line.
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
})
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
const asked: string[] = []
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (exclude) asked.push(exclude)
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(asked).toEqual(['motorway', 'toll'])
expect(routes[1].avoids).toBe('toll')
})
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
// The foot profile has no excludable classes; asking earns an InvalidOptions for
// nothing, and a walk has no motorway to avoid in the first place.
const asked: string[] = []
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (exclude) asked.push(exclude)
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
expect(asked).toEqual([])
expect(routes).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => {
// Both public hosts TREK ships with answer 400 here, so without this every long leg
// pays two extra requests against a one-per-second limit to be refused twice.
const asked: string[] = []
server.use(http.get('*/route/v1/driving/*', ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
asked.push(exclude)
return new HttpResponse(null, { status: 400 })
}))
await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
const afterFirst = asked.length
await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
expect(afterFirst).toBe(1)
expect(asked).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
// OSRM reports these in every answer and TREK threw them away since the first route
// was drawn. Without them a place set back from the road looks like it is on the
// route, while the drive really starts somewhere else.
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [
{ location: [10.02, 53.51], distance: 812.5 },
{ location: [13.0, 52.0], distance: 4 },
],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
expect(r.snapped).toEqual([
{ asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
{ asked: [52, 13], at: [52.0, 13.0], meters: 4 },
])
})
it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
// Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
// real number rather than the whole answer being discarded.
expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
expect(r.snapped?.[0].meters).toBeLessThan(1200)
})
it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
// All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
// bad entry would hang the spur off the wrong stop.
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [{ location: [10.0, 53.0] }],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
expect(r.snapped).toBeUndefined()
expect(r.coordinates).toHaveLength(2)
})
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })))
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
})
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
})
})
+11 -286
View File
@@ -1,9 +1,10 @@
import { useSettingsStore } from '../../store/settingsStore'
import { pluginsApi } from '../../api/client'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
import { haversineKm } from '../../utils/geo'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import { formatDistance } from '../../utils/units'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
// the matching profile so walking routes follow footpaths, not the road network.
@@ -13,74 +14,6 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
}
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
driving: 'driving',
walking: 'foot',
cycling: 'bike',
}
/**
* Where to ask for a route.
*
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
* `routing_base_url` and everything routes against that instead; the server has to name
* the same origin in its connect-src, or the browser blocks the requests silently.
*
* A configured base is expected to serve the standard OSRM layout,
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
*/
/**
* A configured base URL without its trailing slashes.
*
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
* slashes, and the value comes from an instance setting somebody types. Linear either
* way in practice, but the regex is the shape a scanner is right to flag.
*/
function withoutTrailingSlashes(value: string): string {
let end = value.length
while (end > 0 && value[end - 1] === '/') end -= 1
return value.slice(0, end)
}
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
if (!configured) return OSRM_PROFILE_BASE[profile]
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
* A routing request the host refused, as opposed to one it answered with "no route".
*
* The public OSRM instances allow roughly one request a second and answer 429 above that.
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
* try again" from "these two points are not connected by road" — and a road trip, which
* asks for every leg of every day, hits the first case constantly while the second is
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
*/
export class RoutingRefusedError extends Error {
constructor(readonly status: number, readonly retryAfterMs: number | null) {
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
this.name = 'RoutingRefusedError'
}
/** True when waiting and asking again is the right response. */
get isRateLimit(): boolean {
return this.status === 429 || this.status === 503
}
}
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
function retryAfterMs(response: Response): number | null {
const raw = response.headers.get('Retry-After')
if (!raw) return null
const seconds = Number(raw)
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
const at = Date.parse(raw)
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
}
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
const routeCache = new Map<string, RouteWithLegs>()
@@ -112,11 +45,11 @@ export async function calculateRoute(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
if (!response.ok) {
throw new RoutingRefusedError(response.status, retryAfterMs(response))
throw new Error('Route could not be calculated')
}
const data = await response.json()
@@ -319,10 +252,10 @@ export async function calculateSegments(
if (!waypoints || waypoints.length < 2) return []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
if (!response.ok) throw new Error('Route could not be calculated')
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
@@ -411,13 +344,10 @@ export async function calculateRouteWithLegs(
return result
}
// 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' =
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
if (!response.ok) throw new Error('Route could not be calculated')
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
@@ -444,8 +374,7 @@ export async function calculateRouteWithLegs(
}
)
const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
@@ -454,37 +383,6 @@ export async function calculateRouteWithLegs(
return result
}
/**
* The snap positions out of an OSRM answer, or undefined if it did not describe them.
*
* OSRM reports this in every response and TREK has thrown it away since the first route
* was drawn. `distance` is the straight line from the coordinate we sent to the road it
* used; it is recomputed from the two points when the field is missing or nonsense, so a
* mirror that trims its answers still produces a usable gap rather than a wrong one.
*
* All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
* and one bad entry would put a spur on the wrong stop.
*/
function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
const raw = (data as { waypoints?: unknown })?.waypoints
if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
const out: SnappedWaypoint[] = []
for (let i = 0; i < raw.length; i++) {
const loc = (raw[i] as { location?: unknown })?.location
if (!Array.isArray(loc) || loc.length < 2) return undefined
const [lng, lat] = loc as [number, number]
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const at: [number, number] = [lat, lng]
const reported = (raw[i] as { distance?: unknown })?.distance
const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
? reported
: haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
out.push({ asked, at, meters })
}
return out
}
function getDistanceUnit(): DistanceUnit {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
}
@@ -505,176 +403,3 @@ function formatDuration(seconds: number): string {
}
return `${m} min`
}
/** One way of driving a leg, as OSRM offers it. */
export interface RouteAlternative {
coordinates: [number, number][]
distance: number
duration: number
/** Where this route differs most from the first one — the point that would pin it. */
divergence: { lat: number; lng: number } | null
/** Set when this way exists because a road class was left out of it. */
avoids?: 'motorway' | 'toll'
}
/**
* Road classes worth asking the router to leave out, in the order they are tried.
*
* OSRM's own alternative search is conservative: it only offers a second road when the
* detour is short enough and shares little enough with the first, so on a long motorway
* run it usually answers with exactly one route — while a perfectly good, slower way over
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
* a class asks a different question and reliably produces that road.
*
* Only for driving: the foot and bike profiles have no excludable classes, and asking
* anyway earns an `InvalidOptions` for nothing.
*/
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
/** Two lines are the same road if their length and their duration agree closely enough. */
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
/**
* Hosts that have already answered "I do not do exclude", by base URL.
*
* Both public hosts TREK ships with reject the parameter outright with HTTP 400: the
* upstream car profile is built without excludable classes, and only a self-hosted OSRM
* built through the MLD pipeline supports it. Without this, every long leg on a default
* install pays two extra requests to be told twice that it cannot have what it asked for,
* against a host that allows one request per second.
*
* The first real attempt is the probe; there is no separate one. Only a 400 is recorded,
* because only a 400 is about the host rather than about this particular question: a 429
* is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no
* way round the motorway while the next one might.
*
* Per base URL rather than global, so pointing the instance at an own router that does
* support it starts asking again without a reload.
*/
const excludeUnsupported = new Set<string>()
/**
* One route with a road class left out, or null when the router will not or cannot.
*
* Every failure is a null: a rate limit answers 429, and a leg with no way round the
* motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this
* particular question had no answer.
*/
async function routeExcluding(
coords: string,
profile: 'driving' | 'walking' | 'cycling',
exclude: string,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
const base = routeBaseFor(profile)
if (excludeUnsupported.has(base)) return null
try {
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
if (!response.ok) {
// 400 is the router saying the parameter itself is not available here, which is
// true of every leg from now on. Anything else is about this request alone.
if (response.status === 400) excludeUnsupported.add(base)
return null
}
const data = await response.json()
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
if (!route?.geometry?.coordinates?.length) return null
return {
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
distance: route.distance,
duration: route.duration,
divergence: null,
}
} catch {
return null
}
}
/**
* The ways of driving one leg, not one day.
*
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
* asking for a whole day would mean splitting it into one request per leg — which is the
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
*
* `divergence` is the point on each alternative that lies furthest from the default route.
* 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.
*/
export async function calculateAlternatives(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
distance: r.distance,
duration: r.duration,
divergence: null,
}))
// Nothing but the router's own preference came back, which is what it answers on most
// long legs. Rather than reporting "only one sensible way", ask a different question:
// the same drive without the motorway, and failing that without the tolls. Tried one at
// a time and stopped as soon as one lands, so a leg that does have a second road costs
// one extra request rather than three.
//
// Worth knowing before reading further: on the two public hosts TREK ships with, this
// never produces anything. Their car profile is built without excludable classes and
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
// way to drive them. It works against a self-hosted OSRM built through the MLD
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
if (routes.length < 2 && profile === 'driving') {
for (const exclude of EXCLUDABLE_CLASSES) {
if (signal?.aborted) break
const detour = await routeExcluding(coords, profile, exclude, signal)
if (detour && !routes.some(r => sameRoad(r, detour))) {
detour.avoids = exclude
routes.push(detour)
break
}
}
}
// 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 point of `line` that lies furthest from `reference`, in plain degrees. */
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: 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 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
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
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
}
return best
}
+1 -91
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable } from './markerDrag'
/**
* jsdom has no drag implementation, so these drive the events the browser would
@@ -82,93 +82,3 @@ describe('makeMarkerDraggable', () => {
expect(window.__dragData).toBeNull()
})
})
describe('makePoiDraggable', () => {
afterEach(() => {
window.__dragData = null
document.body.classList.remove('dragging-poi')
})
const dragEvent = (type: string) => {
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
const store: Record<string, string> = {}
Object.defineProperty(e, 'dataTransfer', {
value: {
setData: (k: string, v: string) => { store[k] = v },
getData: (k: string) => store[k] ?? '',
effectAllowed: '',
dropEffect: '',
},
})
return e
}
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
expect(el.getAttribute('draggable')).toBe('true')
const e = dragEvent('dragstart')
el.dispatchEvent(e)
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
// Mirrored on window because dataTransfer is unreadable during dragover, and the
// map has to know what is coming while it is still being hovered.
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
})
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
el.dispatchEvent(dragEvent('dragstart'))
expect(document.body.classList.contains('dragging-poi')).toBe(true)
el.dispatchEvent(dragEvent('dragend'))
expect(document.body.classList.contains('dragging-poi')).toBe(false)
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
const seen = vi.fn()
document.body.appendChild(el)
document.body.addEventListener('mousedown', seen)
el.dispatchEvent(down)
expect(seen).not.toHaveBeenCalled()
document.body.removeEventListener('mousedown', seen)
el.remove()
})
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
const el = document.createElement('div')
const off = makePoiDraggable(el, 'node:9')
off()
expect(el.hasAttribute('draggable')).toBe(false)
el.dispatchEvent(dragEvent('dragstart'))
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const start = dragEvent('dragstart')
el.dispatchEvent(start)
expect(draggedPoiId(start)).toBe('node:9')
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBe('node:9')
})
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBeNull()
})
})
-48
View File
@@ -48,51 +48,3 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
el.removeEventListener('mousedown', onMouseDown)
}
}
/**
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
*
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
* just as well once it is projected onto the routed geometry.
*/
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
el.setAttribute('draggable', 'true')
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('poiOsmId', osmId)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { poiOsmId: osmId }
el.classList.add('marker-dragging')
document.body.classList.add('dragging-poi')
}
const onDragEnd = () => {
window.__dragData = null
el.classList.remove('marker-dragging')
document.body.classList.remove('dragging-poi')
}
// Same reason as above: without this the map pans out from under the pointer as the
// drag begins, and the route is impossible to aim at.
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
el.addEventListener('dragstart', onDragStart)
el.addEventListener('dragend', onDragEnd)
el.addEventListener('mousedown', onMouseDown)
return () => {
el.removeAttribute('draggable')
el.removeEventListener('dragstart', onDragStart)
el.removeEventListener('dragend', onDragEnd)
el.removeEventListener('mousedown', onMouseDown)
}
}
/** The OSM id being dragged, when a corridor hit is in flight. */
export function draggedPoiId(e: DragEvent): string | null {
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
if (fromTransfer) return fromTransfer
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
return window.__dragData?.poiOsmId ?? null
}
+3 -34
View File
@@ -1,8 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
// The road-trip kinds take their colour from the one place that defines it, so the
// search result, the map pin and the road-trip rail cannot drift apart.
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
// The POI categories shown in the map "explore" pill. The `key` is the contract
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
@@ -19,27 +15,15 @@ export const POI_CATEGORIES: PoiCategory[] = [
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
// The colour lives with the road-trip categories, because the corridor search offers
// the same one and two definitions of a blue is how they drift apart.
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
]
/**
* What a drive needs. Kept out of POI_CATEGORIES on purpose: these are not offered by
* the explore pill, they are what the road trip corridor searches for. They are in the
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
* instead of the nameless grey dot an unknown category falls back to.
*/
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
)
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
POI_CATEGORIES.map(c => [c.key, c]),
)
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
@@ -50,25 +34,10 @@ export interface Poi {
lng: number
category: string
poi_type: string
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
brand?: string | null
brand_wikidata?: string | null
address: string | null
website: string | null
phone: string | null
opening_hours: string | null
cuisine: string | null
/**
* What a charging station offers, when it is one and OSM says so.
*
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
* own answer here and must not be read as a no.
*/
charging?: {
sockets: { type: string; count: number | null; kw: number | null }[]
capacity: number | null
fee: boolean | null
} | null
source: 'openstreetmap'
}
@@ -1,68 +0,0 @@
import { renderHook } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useStableVias } from './viaMarkerState'
import type { RoadtripVia } from '@trek/shared'
/**
* The list both map renderers rebuild their via handles from.
*
* Its identity is the whole contract: the GL renderer replaces the DOM element when this
* changes, and a pointerdown belongs to the element it happened on. A list that changed
* on every render meant a freshly placed via lost its first drag to the re-route landing
* underneath it.
*/
const via = (over: Partial<RoadtripVia> & { id: number }): RoadtripVia => ({
day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1, ...over,
})
describe('useStableVias', () => {
it('FE-VIASTATE-001: the same vias in a fresh object are the same list', () => {
// Exactly what a reload hands down: identical rows, a new object every time.
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5 })] } })
expect(result.current).toBe(first)
})
it('FE-VIASTATE-002: a via that moved is a new list, so the handle is redrawn where it now is', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5, lat: 48.9 })] } })
expect(result.current).not.toBe(first)
expect(result.current[0].lat).toBe(48.9)
})
it('FE-VIASTATE-003: adding or removing one is a new list too', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5 }), via({ id: 6, lat: 48.4 })] } })
expect(result.current).not.toBe(first)
expect(result.current).toHaveLength(2)
})
it('FE-VIASTATE-004: every day of the trip is in one list, and no vias is a stable empty one', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })], 2: [via({ id: 6, day_id: 2 })] } as Record<number, RoadtripVia[]> | undefined },
})
expect(result.current.map(v => v.id)).toEqual([5, 6])
rerender({ byDay: undefined })
const empty = result.current
expect(empty).toEqual([])
rerender({ byDay: {} })
expect(result.current).toBe(empty)
})
})
@@ -1,30 +0,0 @@
import { useRef } from 'react'
import type { RoadtripVia } from '@trek/shared'
/**
* The trip's via points as an array that only changes when a via does.
*
* Both map renderers rebuild their via handles whenever this list changes, and the GL one
* replaces the DOM element outright. The list it was given changed on every render: the
* hook that loads vias returns a fresh object each time, and a fresh object arrives again
* on every reload even when the rows came back identical.
*
* That made dragging a freshly placed via fail on the first try. Placing one triggers a
* re-route, the re-route lands about a second later, the render it causes replaced the
* handle under the pointer — and a pointerdown belongs to the element it happened on, so
* the drag went to an element that no longer existed. The second attempt worked because
* by then nothing was pending. An older via never showed it: nothing was re-routing.
*
* Keyed by id and position because those are the only fields a handle is built from. A
* via that genuinely moved gets a new list, which is what redraws it where it now is.
*/
export function useStableVias(viasByDay: Record<number, RoadtripVia[]> | undefined): RoadtripVia[] {
const flat = Object.values(viasByDay ?? {}).flat()
const key = flat.map(v => `${v.id}@${v.lat},${v.lng}`).join('|')
// Read and written during render on purpose: this is a cache of the argument, not
// state — it never triggers a render of its own, and it has to be settled before the
// effects that consume the list run.
const held = useRef<{ key: string; list: RoadtripVia[] }>({ key: '', list: [] })
if (held.current.key !== key) held.current = { key, list: flat }
return held.current.list
}
-16
View File
@@ -353,22 +353,6 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'car') {
// A rental with stops is a drive, and the printout is what people take
// into the car (#1797). Without this the route reads as pick-up and return
// with everything in between missing.
//
// Consecutive repeats collapse, and a chain that names one place only is
// dropped: the ordinary rental is picked up and returned at the same desk,
// which satisfies "two endpoints" and would otherwise print the counter's
// name twice with an arrow between. A genuine round trip keeps both ends,
// because Dresden sits between them.
const stops = (r.endpoints || []).slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => e.name)
.filter((name, i, all) => name && name !== all[i - 1])
subtitle = new Set(stops).size >= 2 ? stops.join(' → ') : ''
}
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
import { MapPin } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
interface Props {
value: string
@@ -17,8 +16,6 @@ interface Props {
// is never lost, and picking a suggestion just replaces the text (#1496).
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
const [highlight, setHighlight] = useState(-1)
@@ -50,7 +47,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
const myReq = ++reqIdRef.current
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale, locationBias)
const data = await mapsApi.search(trimmed, locale)
if (myReq !== reqIdRef.current) return
setResults(data.places || [])
setHighlight(-1)
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
import { MapPin, X } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
export interface LocationPoint {
name: string
@@ -20,8 +19,6 @@ interface Props {
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [query, setQuery] = useState(value?.name || '')
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
@@ -52,7 +49,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale, locationBias)
const data = await mapsApi.search(trimmed, locale)
setResults(data.places || [])
setHighlight(-1)
} catch {
@@ -169,28 +169,6 @@ describe('PlaceDetailsColumn', () => {
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
})
it('FE-PDC-010b: credits a website description by host', async () => {
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
// restaurant's own marketing copy would have been credited to an
// encyclopaedia. There is no fixed name for "the place's own website", and
// the host is the honest one.
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: {
text: 'Pizza in Rostock, seit 2015.',
source: 'website',
sourceUrl: 'https://www.losteria.net/rostock',
license: null,
},
})
renderColumn()
const link = await screen.findByRole('link', { name: /losteria\.net/ })
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
})
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
renderColumn()
@@ -379,37 +379,6 @@ function PhotoTile({
)
}
/**
* Who the description came from, as the reader would name them.
*
* `website` has no fixed name — it is the place's own site — so it is credited
* by host: "losteria.net" says where the text is from far better than a generic
* word would. Deliberately not a ternary chain: the previous one fell through
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
* encyclopaedia.
*/
function descriptionSourceLabel(
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
sourceUrl: string | null,
): string {
switch (source) {
case 'google':
return 'Google'
case 'osm':
return 'OpenStreetMap'
case 'wikivoyage':
return 'Wikivoyage'
case 'wikipedia':
return 'Wikipedia'
case 'website':
try {
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
} catch {
return 'Website'
}
}
}
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
@@ -690,7 +659,14 @@ function DescriptionBlock({
}): React.ReactElement | null {
if (!description) return null
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
const sourceLabel =
description.source === 'google'
? 'Google'
: description.source === 'osm'
? 'OpenStreetMap'
: description.source === 'wikivoyage'
? 'Wikivoyage'
: 'Wikipedia'
// A description of the chain is not a description of this place, and the
// heading is where that gets said. Without it the reader has no way to tell
@@ -1,5 +1,3 @@
import type { RoadtripStopType } from '@trek/shared'
export interface PlaceFormData {
name: string
description: string
@@ -26,14 +24,6 @@ export interface PlaceFormData {
// submit payload when unchanged, and useTripPlanner strips it off the place
// update and PUTs it per assignment instead.
assignment_notes?: string
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
// place created outside a road trip has no opinion about fuel stops.
stop_type?: RoadtripStopType | null
// Offered only while creating, and only alongside a stop_type: the corridor popup
// suggests how long that kind of pause usually takes, and the full form should not
// throw the suggestion away on the way through. Editing a stay is the rail's job.
duration_minutes?: number
}
export function isGoogleMapsUrl(input: string): boolean {
+32 -100
View File
@@ -1,16 +1,13 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import type { RoadtripStopType } from '@trek/shared'
import { STOP_KINDS } from '../Roadtrip/stopKinds'
import CustomSelect from '../shared/CustomSelect'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
import { recordPlacePick } from '../../api/placeShadow'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
@@ -19,7 +16,6 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { Place, Category, Assignment, BudgetItem } from '../../types'
@@ -40,7 +36,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -90,20 +86,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
onOpenExpense,
} = props
// Hidden while the addon is off, because the kinds only mean anything to the road trip
// rail: on an instance without it they would be six labels that change nothing.
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
* state because nothing renders from it and a pick must read the value that
* belonged to the list, not a value a re-render replaced.
*/
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
@@ -158,11 +143,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
// Carried through every edit. Without it, opening a fuel stop to fix a typo
// submits an empty kind and turns it back into a numbered destination.
// duration_minutes deliberately stays out: how long a stay takes belongs to the
// rail's own dialog, and sending it from here would overwrite what was set there.
stop_type: place.stop_type ?? null,
// The day-specific note rides only with an assignment in context (#2163);
// otherwise the key stays absent so submit never sends a notes write.
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
@@ -177,8 +157,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
stop_type: prefillCoords.stop_type ?? null,
duration_minutes: prefillCoords.duration_minutes,
})
} else {
setForm(DEFAULT_FORM)
@@ -235,11 +213,32 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}, [isOpen])
// Derive location bias bounding box from the trip's existing places
const places = useTripStore((s) => s.places)
// Where the trip is happening — the hint that tells the search which of a
// thousand identically named places is meant. Autocomplete wants a box, the
// search wants a point; useLocationBias derives both from the same places.
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
const locationBias = useMemo(() => {
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
if (withCoords.length === 0) return undefined
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
for (const p of withCoords) {
const lat = Number(p.lat), lng = Number(p.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
if (lng < minLng) minLng = lng
if (lng > maxLng) maxLng = lng
}
if (!Number.isFinite(minLat)) return undefined
// Skip bias if the bounding box is too large (~500 km diagonal)
const dlat = maxLat - minLat
const dlng = maxLng - minLng
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
if (diagKm > 500) return undefined
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
}, [places])
// Autocomplete fetch — aborts any in-flight request before starting a new one
const fetchSuggestions = useCallback(async (query: string) => {
@@ -253,7 +252,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
acAbortRef.current = controller
try {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query, source: result.source || 'unknown' }
setAcSuggestions(result.suggestions || [])
setAcHighlight(-1)
} catch (err: unknown) {
@@ -312,8 +310,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return
}
}
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
const result = await mapsApi.search(mapsSearch, language)
setMapsResults(result.places || [])
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
@@ -322,13 +319,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}
/**
* `pick` is present only when the click came from a ranked list. The
* collection picker and the autocomplete detour reach this function with a
* place that was never ranked against a query, and a made-up rank would be
* worse than no row at all.
*/
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
const handleSelectMapsResult = (result) => {
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
// The one point every pick flows through, so the detail column hangs here.
// A new pick drops whatever hero image belonged to the previous place.
@@ -345,36 +336,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
details: result,
})
setForm(prev => ({ ...prev, image_url: undefined }))
if (pick) {
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
if (meta) {
recordPlacePick({
query: meta.query,
lang: language,
// The bias the search actually ran under is a box around the trip's
// existing places; the corpus stores its centre, which is what an
// evaluation needs to bias its own index the same way.
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
source: `${pick.mode}:${meta.source}`,
liveRank: pick.rank,
liveCount: pick.count,
pickedName: result.name || '',
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: result.google_place_id || result.osm_id || null,
})
}
}
}
setMapsResults([])
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
// Read before the list is cleared: this is the rank the user saw.
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
const acCount = acSuggestions.length
setAcSuggestions([])
setAcHighlight(-1)
const previousSearch = mapsSearch
@@ -401,11 +368,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language, locationBiasPoint)
const search = await mapsApi.search(query, language)
place = search.places?.[0] ?? null
}
if (place) {
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
handleSelectMapsResult(place)
} else {
setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError'))
@@ -512,15 +479,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
// An explicit null is how a stop stops being a fuel stop; the service reads it
// that way rather than as "leave alone", which is what a missing key means.
stop_type: form.stop_type || null,
// Only on the way in, and only with a kind: it is the popup's suggestion for how
// long that kind of pause takes. On an edit it is left out entirely, because the
// stay belongs to the rail's dialog and sending it here would overwrite it.
...(!place && form.stop_type && form.duration_minutes
? { duration_minutes: form.duration_minutes }
: {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
}
// #2163: the per-assignment note only travels when an assignment is in
@@ -569,7 +527,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -642,7 +599,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -812,7 +768,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<button
key={idx}
type="button"
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
onClick={() => handleSelectMapsResult(result)}
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
>
<div className="font-medium text-sm">{result.name}</div>
@@ -914,30 +870,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
</div>
{/* What kind of stop this is on a drive.
Under the category rather than beside it, because the two answer different
questions: a category is the traveller's own label, editable and shared across
the whole instance, while this is a fact about the place. Refuelling is not a
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
is still a fuel stop when the mode is switched on later, and this is the only
place in the web UI where the kind can be taken off again. */}
{roadtripEnabled ? (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
<CustomSelect
value={form.stop_type ?? ''}
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
placeholder={t('roadtrip.stop.none')}
options={[
{ value: '', label: t('roadtrip.stop.none') },
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
]}
size="sm"
/>
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
</div>
) : null}
{/* Category */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
@@ -43,9 +43,6 @@ export function ListImportModal(S: SidebarState) {
)}
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
{/* Same box, same Share button: which screen the link came from is the URL's
business, and until it said so nobody knew a route could be pasted here. */}
{listImportProvider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
</div>
<input
type="text"
@@ -175,29 +175,6 @@ describe('TransitSearchPanel', () => {
await user.click(await screen.findByText(/London Victoria/))
}
/**
* An empty result is ambiguous without the backend's name: the fallback to
* Transitous is silent, so "no connections" could mean the provider has no
* data here or that the provider you picked never ran (#1699).
*/
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
@@ -8,7 +8,6 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import type { Day, Place, Accommodation } from '../../types'
import type { TransitProvider } from '@trek/shared'
/**
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
@@ -37,14 +36,6 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str
export interface PickedPlace { name: string; lat: number; lng: number }
// Backend names as they are written, not translated — the empty state says
// which one answered so "nothing here" can be told apart from "not the one you
// picked" (#1699).
const PROVIDER_NAMES: Record<TransitProvider, string> = {
transitous: 'Transitous',
google: 'Google',
}
// ── helpers ──────────────────────────────────────────────────────────────────
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
@@ -349,7 +340,7 @@ interface TransitSearchPanelProps {
}
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
const { t, language } = useTranslation()
const { t } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -364,7 +355,6 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const [loading, setLoading] = useState(false)
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
const [addingIdx, setAddingIdx] = useState<number | null>(null)
const [provider, setProvider] = useState<TransitProvider | null>(null)
// Quick picks: the day's located places, plus the trip's located accommodations.
const quickPicks = useMemo<PickedPlace[]>(() => {
@@ -397,7 +387,6 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
setLoading(true)
setItineraries(null)
setExpandedIdx(null)
setProvider(null)
try {
const tzFrom = tzAt(from.lat, from.lng)
const tzTo = tzAt(to.lat, to.lng)
@@ -406,11 +395,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
const allModes = activeModes.size === MODE_GROUPS.length
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language })
// Which backend actually answered (#1699). Named in the empty state so
// "nothing found" can be told apart from "the provider you picked never
// ran" — the fallback to Transitous is silent by design.
setProvider(d.provider ?? null)
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
// MOTIS names the request coordinates START/END — swap in the places the
// user actually picked so walks read "Walk to Zoologischer Garten".
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
@@ -621,9 +606,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
)}
{!loading && ranked && ranked.length === 0 && (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{provider
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
: t('transit.noResults')}
{t('transit.noResults')}
</div>
)}
{!loading && ranked && ranked.length > 0 && (
@@ -1192,93 +1192,6 @@ describe('TransportModal', () => {
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
// Pick-up and return are the rental frame; the stops sit between them.
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
// Pick-up and return come first in the form, the stops follow — so the saved
// route is from · stop · stop · to even though the fields read in another order.
const fields = screen.getAllByTestId('location-select');
expect(fields).toHaveLength(4);
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
});
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const fields = screen.getAllByTestId('location-select');
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
// The order of the stops is the order they are driven, and `sequence` is derived
// from it on save — so without this the only way to swap two was to retype both.
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
});
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
// A single stop has nothing to swap with, so the buttons stay away entirely.
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const up = screen.getAllByRole('button', { name: /Move up/i });
const down = screen.getAllByRole('button', { name: /Move down/i });
expect(up[0]).toBeDisabled();
expect(down[down.length - 1]).toBeDisabled();
});
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
const fields = screen.getAllByTestId('location-select');
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -116,19 +116,6 @@ interface StationWaypointForm {
seat: string
confirmation_number: string
}
// ── Car stops along the drive ──────────────────────────────────────────────
//
// A rental is one booking with one pick-up and one return, so — unlike a flight or a
// train — the stops in between are not legs of their own: they are places the drive
// passes through, each with an optional time the driver plans to be there. They persist
// as the same `role: 'stop'` endpoints every other transport type already uses.
interface CarStopForm {
location: LocationPoint | null
time: string
}
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
@@ -213,20 +200,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
// Train route as an ordered list of stations (origin .. stops .. destination).
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
// A car keeps its pick-up and return as the frame of the rental and gains the stops
// in between (#1797): one booking, one continuous drive, several places along it.
const [carStops, setCarStops] = useState<CarStopForm[]>([])
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
const moveCarStop = (index: number, delta: number): void => {
setCarStops(prev => {
const to = index + delta
if (to < 0 || to >= prev.length) return prev
const next = [...prev]
;[next[index], next[to]] = [next[to], next[index]]
return next
})
}
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
@@ -373,17 +346,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
// Stops persist for every type; only a car offers an editor for them, so only a
// car reads them back into one. The others keep passing theirs through untouched.
setCarStops(
src.type === 'car'
? (src.endpoints ?? [])
.filter(e => e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
: [],
)
}
} else {
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
@@ -392,7 +354,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
setCarStops([])
}
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
@@ -543,14 +504,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
// A car writes the stops the driver planned; every other type keeps passing the
// itinerary's transfer stops through while the route is unchanged (#1065).
const carEndpoints = form.type === 'car'
? carStops
.filter(s => s.location)
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
: []
const stops = keepTransit && form.type !== 'car'
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
const stops = keepTransit
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: []
stops.forEach((s, i) => endpoints.push({
@@ -558,9 +513,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
}))
carEndpoints.forEach(e => endpoints.push(e))
const stopCount = stops.length + carEndpoints.length
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
}
// Flights and trains derive their span from the first/last waypoint; other
@@ -1002,76 +955,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
</div>
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
pick-up and return; these are the places in between, in order. */}
{form.type === 'car' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label className={labelClass}>{t('roadtrip.stops.label')}</label>
{carStops.map((stop, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
{/* The order of these stops IS the route: `sequence` is the array index
at save time, so the only way to change which one is driven first
was to delete both and re-enter them. Buttons rather than dragging,
because the row already carries a location picker and a time picker
and there is nothing left to grab. */}
{carStops.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
<button
type="button"
onClick={() => moveCarStop(i, -1)}
disabled={i === 0}
aria-label={t('dayplan.moveUp')}
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
style={{ background: 'none', border: 'none', cursor: i === 0 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
>
<ChevronUp size={13} />
</button>
<button
type="button"
onClick={() => moveCarStop(i, 1)}
disabled={i === carStops.length - 1}
aria-label={t('dayplan.moveDown')}
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
style={{ background: 'none', border: 'none', cursor: i === carStops.length - 1 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
>
<ChevronDown size={13} />
</button>
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<LocationSelect
value={stop.location}
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
/>
</div>
<div style={{ width: 110, flexShrink: 0 }}>
<CustomTimePicker
value={stop.time}
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
/>
</div>
<button
type="button"
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
aria-label={t('roadtrip.stops.remove')}
className="text-content-faint hover:text-danger"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 8, display: 'flex', alignItems: 'center' }}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
className="text-content-faint hover:text-content-secondary"
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}
>
<Plus size={12} /> {t('reservations.layover.addStop')}
</button>
</div>
)}
{/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -1,145 +0,0 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
import type { AlternativeOverlay } from './alternativeOverlays'
import type { LegAlternatives } from './useRouteAlternatives'
/**
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map.
*
* Every value shown comes from the same overlay the map draws. Working them out
* here separately is what once made the list label the driven route "Fastest"
* while offering another one as quicker beside it, because it assumed the first
* entry was the quickest — which stopped being true the moment the road
* currently driven was put at the top.
*/
const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay => ({
index: 0,
coordinates: [],
color: '#0a84ff',
label: '3 h',
note: '',
duration: 10_800,
distance: 290_000,
slowerThanQuickest: 0,
labelBg: '#0a84ff',
at: { lat: 53, lng: 11 },
...over,
})
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
})
beforeEach(() => {
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
})
describe('RoadtripAlternativesBar', () => {
it('FE-ALTBAR-001: nothing open means nothing drawn', () => {
const { container } = render(
<RoadtripAlternativesBar open={null} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(container).toBeEmptyDOMElement()
})
it('FE-ALTBAR-002: while the router is answering it says so rather than showing an empty list', () => {
render(
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/asking the router/i)).toBeInTheDocument()
})
it('FE-ALTBAR-003: a router that will not answer says so, and offers nothing', () => {
render(
<RoadtripAlternativesBar open={leg({ error: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/not answering/i)).toBeInTheDocument()
expect(screen.queryAllByRole('button', { name: /km/ })).toHaveLength(0)
})
it('FE-ALTBAR-004: one route back is not a choice, and is said as one', () => {
// Offering a single option would ask the traveller to pick the road they are
// already on.
render(
<RoadtripAlternativesBar open={leg()} overlays={[overlay()]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/only/i)).toBeInTheDocument()
})
it('FE-ALTBAR-005: each offer shows its distance and what it is', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, distance: 310_000, note: 'No motorway' })]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('Fastest')).toBeInTheDocument()
expect(screen.getByText('No motorway')).toBeInTheDocument()
expect(screen.getByText(/290/)).toBeInTheDocument()
expect(screen.getByText(/310/)).toBeInTheDocument()
})
it('FE-ALTBAR-006: an offer with nothing to say about itself says how much slower it is', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, note: '', slowerThanQuickest: 1800 })]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText(/30/)).toBeInTheDocument()
})
it('FE-ALTBAR-007: choosing one reports its own index, not its position', () => {
// The map and the list agree on `index`; a position would drift the moment
// the driven road is prepended.
const onChoose = vi.fn()
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ index: 3 }), overlay({ index: 7 })]}
onChoose={onChoose}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByRole('button')[2])
expect(onChoose).toHaveBeenCalledWith(7)
})
it('FE-ALTBAR-008: pointing at an offer lights that road up, and leaving clears it', () => {
const onHighlight = vi.fn()
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ index: 0 }), overlay({ index: 1 })]}
onChoose={vi.fn()}
onClose={vi.fn()}
onHighlight={onHighlight}
/>,
)
const first = screen.getAllByRole('button')[1]
fireEvent.mouseEnter(first)
expect(onHighlight).toHaveBeenLastCalledWith(0)
fireEvent.mouseLeave(first)
expect(onHighlight).toHaveBeenLastCalledWith(null)
// Keyboard too: the same road lights up on focus.
fireEvent.focus(first)
expect(onHighlight).toHaveBeenLastCalledWith(0)
})
it('FE-ALTBAR-009: closing is always available, even while it is still asking', () => {
const onClose = vi.fn()
render(
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={onClose} />,
)
fireEvent.click(screen.getByRole('button', { name: /close/i }))
expect(onClose).toHaveBeenCalled()
})
})
@@ -1,101 +0,0 @@
import React from 'react'
import { Shuffle, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { LegAlternatives } from './useRouteAlternatives'
import type { AlternativeOverlay } from './alternativeOverlays'
interface RoadtripAlternativesBarProps {
open: LegAlternatives | null
/** The very rows the map draws, so the two can never disagree about which is fastest. */
overlays: AlternativeOverlay[]
onChoose: (index: number) => void
onClose: () => void
/** Reports which option the pointer is on, so the map can light that road up. */
onHighlight?: (index: number | null) => void
}
/**
* The ways of driving one leg, offered over the map.
*
* A bar rather than a dialog: the answer is on the map behind it — which road each option
* takes — so covering the map to ask about it would hide the only thing worth looking at.
*
* Every value here comes from the same overlay the map draws. Working them out separately
* is what made the list label the driven route "Fastest" while offering another one
* "58 min quicker" beside it: it assumed the first entry was the quickest, which stopped
* being true the moment the road currently driven was put at the top.
*/
export default function RoadtripAlternativesBar({
open, overlays, onChoose, onClose, onHighlight,
}: RoadtripAlternativesBarProps): React.ReactElement | null {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
if (!open) return null
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">
<div className="flex items-center gap-2">
<Shuffle size={13} className="shrink-0 text-content-faint" aria-hidden />
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.alt.title')}
</span>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="ms-auto rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={13} aria-hidden />
</button>
</div>
{open.loading ? (
<p className="text-caption text-content-muted">{t('roadtrip.alt.loading')}</p>
) : open.error ? (
<p className="flex items-start gap-1.5 text-caption text-warning">
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
{t('roadtrip.alt.failed')}
</p>
) : overlays.length < 2 ? (
// 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)}
</span>
<span className="text-caption tabular-nums text-content-muted">
{alt.note
? alt.note
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
</span>
</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -1,93 +0,0 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { Fuel, Zap, Utensils } from 'lucide-react'
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.
*
* 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.
*/
const KEYS = ['fuel', 'charging', 'food'] as const
const META = {
fuel: { labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
charging: { labelKey: 'roadtrip.poi.charging', Icon: Zap },
food: { labelKey: 'roadtrip.poi.food', Icon: Utensils },
}
function picker(selected: string[] = [], onToggle = vi.fn()) {
render(<RoadtripCategoryPicker keys={KEYS} meta={META} selected={selected} onToggle={onToggle} />)
return { onToggle, trigger: () => screen.getByRole('button', { expanded: false }) }
}
/** The control that opens the list, whatever its current state. */
const trigger = () => screen.getAllByRole('button')[0]
describe('RoadtripCategoryPicker', () => {
it('FE-CATPICK-001: closed by default, so the column belongs to the results', () => {
picker()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('FE-CATPICK-002: with nothing picked it asks rather than reporting an empty set', () => {
picker()
expect(screen.getByText('Looking for')).toBeInTheDocument()
})
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => {
// Reversed on the way in: the control lists them the way it offers them, not
// the way they happened to be clicked.
picker(['food', 'fuel'])
expect(screen.getByText('Fuel, Food')).toBeInTheDocument()
})
it('FE-CATPICK-004: opening lists every kind', () => {
picker()
fireEvent.click(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
for (const label of ['Fuel', 'Charging', 'Food']) {
expect(screen.getAllByText(label).length).toBeGreaterThan(0)
}
})
it('FE-CATPICK-005: picking one reports it and leaves the list open', () => {
// The whole reason it is multi-select: two kinds is one gesture.
const { onToggle } = picker()
fireEvent.click(trigger())
fireEvent.click(screen.getByText('Charging'))
expect(onToggle).toHaveBeenCalledWith('charging')
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
})
it('FE-CATPICK-006: picking a kind that is already on reports it too, for the toggle', () => {
const { onToggle } = picker(['fuel'])
fireEvent.click(trigger())
fireEvent.click(screen.getAllByText('Fuel')[1] ?? screen.getByText('Fuel'))
expect(onToggle).toHaveBeenCalledWith('fuel')
})
it('FE-CATPICK-007: a click outside closes it, a click inside does not', () => {
picker()
fireEvent.click(trigger())
fireEvent.mouseDown(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
fireEvent.mouseDown(document.body)
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('FE-CATPICK-008: Escape closes it', () => {
picker()
fireEvent.click(trigger())
fireEvent.keyDown(document, { key: 'Escape' })
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
})
@@ -1,123 +0,0 @@
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { serviceColor } from './roadtripModel'
import { FS } from './typeScale'
interface RoadtripCategoryPickerProps {
/** Every kind the corridor can look for, in the order they are offered. */
keys: readonly string[]
meta: Record<string, { labelKey: string; Icon: LucideIcon }>
selected: readonly string[]
onToggle: (key: string) => void
}
/**
* What to look for, as one full-width control.
*
* Six pills wrapped onto three lines and took the height of the whole card for a set of
* answers that rarely changes. Folded into a dropdown it is one line, and the space goes
* to the results — which is what the column is actually for.
*
* 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.
*/
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])
// 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')
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>
{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"
>
{keys.map(key => {
const { labelKey, Icon } = meta[key]
const on = selected.includes(key)
return (
<button
key={key}
type="button"
role="option"
aria-selected={on}
// 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"
>
<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
>
<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'}`}>
{t(labelKey)}
</span>
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
</button>
)
})}
</div>
) : null}
</div>
)
}
@@ -1,349 +0,0 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripCorridorPanel from './RoadtripCorridorPanel'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripCorridor } from './useRoadtripCorridor'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
const stop = (id: number, name: string): RoadtripStop => ({
assignmentId: id,
placeId: id * 10,
name,
lat: 53.5,
lng: 9.9,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
})
const day = (dayId: number, dayNumber: number): RoadtripDay => ({
dayId,
dayNumber,
date: null,
title: null,
stops: [stop(dayId * 10 + 1, 'Hamburg'), stop(dayId * 10 + 2, 'Berlin')],
legs: [],
schedule: { entries: [], warnings: [] },
legVias: [], driveWarnings: [], dayWarning: null,
// Routed. Searching before the day has a geometry builds the corridor from the
// straight line between the stops, and the routing answer landing a second
// later clears the search mid-flight — so the button waits for this.
geometry: [[53.55, 9.99], [52.52, 13.4]] as [number, number][],
distance: 0,
duration: 0,
})
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days,
lines: [],
accessLines: [],
vias: [],
segments: [],
totalDistance: 0,
totalDuration: 0,
totalStops: days.length * 2,
quietDays: [],
loading: false,
})
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
return {
lat: 53.5,
lng: 9.9,
category: 'fuel',
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
offRouteKm: 1.2,
alongKm: 40,
...over,
} as CorridorPoi
}
function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<RoadtripCorridor['search']> = {}): RoadtripCorridor {
const days = over.day ? [over.day] : [day(1, 1)]
const searchState: RoadtripCorridor['search'] = {
results: [],
progress: { done: 0, total: 0 },
loading: false,
capped: false,
failedAreas: 0,
truncatedAreas: 0,
error: false,
spine: [],
search: vi.fn(),
clear: vi.fn(),
...search,
}
return {
dayId: String(days[0].dayId),
setDayId: vi.fn(),
day: days[0],
categories: ['fuel'],
toggleCategory: vi.fn(),
widthKm: 5,
setWidthKm: vi.fn(),
nameFilter: '',
anchors: [],
section: null,
setSection: vi.fn(),
sectionKm: 50,
setSectionKm: vi.fn(),
socketFilter: '',
setSocketFilter: vi.fn(),
minKw: 0,
setMinKw: vi.fn(),
setNameFilter: vi.fn(),
// Same as the hook with an empty filter: everything found is on show.
visible: searchState.results,
insertIndexFor: vi.fn(() => 1),
stopsAlongKm: [0, 100],
...over,
search: searchState,
}
}
describe('RoadtripCorridorPanel', () => {
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)])} />)
expect(c.search.search).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: /search/i }))
expect(c.search.search).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-PANEL-004: a day that has not routed yet cannot be searched along', () => {
// Until the day routes, the corridor is the straight line between the stops,
// so the boxes march across whatever lies between them instead of along the
// roads driven. Worse, the routing answer landing a second later changes the
// line, which clears the search mid-flight and drops every result with no
// error and no explanation. Routing runs one day at a time, about a second
// apart, so on a long trip that window is wide open.
const unrouted = { ...day(1, 1), geometry: [] as [number, number][] }
const c = corridor({ day: unrouted })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([unrouted])} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-005: nor while the routing is still running', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={{ ...routes([day(1, 1)]), loading: true }} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-002: nothing selected means nothing to search for', () => {
const c = corridor({ categories: [] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-003: a day with a single stop has no drive to search along', () => {
const lonely = { ...day(1, 1), stops: [stop(11, 'Hamburg')] }
const c = corridor({ day: lonely })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([lonely])} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-004: toggling a kind reports which one', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// Six kinds used to be six pills wrapped over three lines; they are one dropdown now,
// so the options only exist once it is open.
fireEvent.click(screen.getByRole('button', { expanded: false }))
fireEvent.click(screen.getByRole('option', { name: /campsite/i }))
expect(c.toggleCategory).toHaveBeenCalledWith('campsite')
// What is already selected reads as selected rather than only looking different.
expect(screen.getByRole('option', { name: /fuel/i })).toHaveAttribute('aria-selected', 'true')
})
it('FE-ROADTRIP-PANEL-005: the corridor width is a choice, and the current one is marked', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
const five = screen.getByRole('button', { name: '5 km' })
expect(five).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: '10 km' }))
expect(c.setWidthKm).toHaveBeenCalledWith(10)
})
it('FE-ROADTRIP-PANEL-006: the day picker only appears once there is more than one drive', () => {
const one = corridor()
const { unmount } = wrap(<RoadtripCorridorPanel corridor={one} routes={routes([day(1, 1)])} />)
expect(screen.queryByText('Day 1')).not.toBeInTheDocument()
unmount()
wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />)
expect(screen.getByText('Day 1')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-007: groups the hits by kind and counts each group', () => {
const c = corridor({ categories: ['fuel', 'campsite'] }, {
results: [
poi({ osm_id: 'a', name: 'Aral', alongKm: 10 }),
poi({ osm_id: 'b', name: 'Shell', alongKm: 20 }),
poi({ osm_id: 'c', name: 'Camping Elbe', category: 'campsite', alongKm: 30 }),
],
})
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// "Fuel" also names an option in the kind picker; the group header is the one
// inside a <header>.
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)!
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-008: each hit says how far off the route and how far along it is', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', offRouteKm: 1.2, alongKm: 40 })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
const row = screen.getByText('Aral').closest('li')!
expect(within(row).getByText(/1\.2 km/)).toBeInTheDocument()
expect(within(row).getByText(/40 km/)).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-009: a hit right at the start says so instead of "0 km along"', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', alongKm: 0.1 })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('Aral').closest('li')!.textContent).not.toContain('0 km along')
})
it('FE-ROADTRIP-PANEL-010: a chain gets its category icon, never its own logo', () => {
// A corridor is mostly chains, and their marks turned a list of petrol stations into
// an advertisement — while a brand with no logo on file fell back to a different
// picture, so no two rows looked alike. The category's icon is also what the rail and
// the map draw for the same stop.
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', brand_wikidata: 'Q565734' } as Partial<CorridorPoi> & { osm_id: string; name: string })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
const row = screen.getByText('Aral').closest('li')!
expect(row.querySelector('img')).toBeNull()
expect(row.querySelector('svg.lucide-fuel')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-012: adding a hit puts it on the day that was searched', () => {
const onAddPoi = vi.fn()
const c = corridor({}, { results: [poi({ osm_id: 'node:9', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
fireEvent.click(screen.getByRole('button', { name: /add/i }))
// Day and position both: a fuel stop belongs where it is driven past, not at the end.
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 1)
})
it('FE-ROADTRIP-PANEL-023: the position offered is the one worked out for that very hit', () => {
const onAddPoi = vi.fn()
const hit = poi({ osm_id: 'node:9', name: 'Aral', alongKm: 120 })
const insertIndexFor = vi.fn(() => 3)
const c = corridor({ insertIndexFor }, { results: [hit] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
fireEvent.click(screen.getByRole('button', { name: /add/i }))
expect(insertIndexFor).toHaveBeenCalledWith(hit)
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 3)
})
it('FE-ROADTRIP-PANEL-013: while searching it reports progress rather than sitting still', () => {
const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25')
expect(screen.getByRole('button', { name: /3/ })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-014: a partial answer says which stretches nobody looked at', () => {
const c = corridor({}, { failedAreas: 2, results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// The difference between "no fuel here" and "nobody looked here".
expect(screen.getByText('2 stretches could not be searched — the place search did not answer.')).toBeInTheDocument()
expect(screen.getByText('Aral')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => {
const c = corridor({}, { error: true, failedAreas: 4 })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('The place search is not answering right now.')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-016: a drive longer than the budget admits the tail went unsearched', () => {
const c = corridor({}, { capped: true })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
const empty = corridor()
const { unmount } = wrap(<RoadtripCorridorPanel corridor={empty} routes={routes([day(1, 1)])} />)
expect(screen.queryByLabelText('Filter by name')).not.toBeInTheDocument()
unmount()
const found = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={found} routes={routes([day(1, 1)])} />)
expect(screen.getByLabelText('Filter by name')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-019: typing in the filter reports the term rather than searching again', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
fireEvent.change(screen.getByLabelText('Filter by name'), { target: { value: 'shell' } })
expect(c.setNameFilter).toHaveBeenCalledWith('shell')
// The filter must never trigger another round of Overpass requests.
expect(c.search.search).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-PANEL-020: the list shows what is visible, and counts it against the whole', () => {
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
const c = corridor({ nameFilter: 'shell', visible: [hits[1]] }, { results: hits })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('Shell')).toBeInTheDocument()
expect(screen.queryByText('Aral')).not.toBeInTheDocument()
expect(screen.getByText('1 of 2 on the way')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-021: filtering everything away says so instead of looking unsearched', () => {
const c = corridor(
{ nameFilter: 'esso', visible: [] },
{ results: [poi({ osm_id: 'a', name: 'Aral' })] },
)
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText(/Nothing on the way matches/)).toBeInTheDocument()
expect(screen.queryByText('Pick what you need and search.')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-022: without permission to add, no hit offers an Add button', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.queryByText('Add')).not.toBeInTheDocument()
})
})
@@ -1,555 +0,0 @@
import React, { useMemo } from 'react'
import {
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
} from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
import { serviceColor } from './roadtripModel'
import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
import { FS } from './typeScale'
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripRoutes } from './useRoadtripRoutes'
interface RoadtripCorridorPanelProps {
corridor: RoadtripCorridor
routes: RoadtripRoutes
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
onAddPoi?: (
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
dayId?: number | null,
position?: number | null,
) => void
}
/**
* How each socket family is written on the plug.
*
* Not translated and not derived from the tag: these are proper nouns with an accepted
* spelling, and "Type 2" as a lowercase tag key is not what anybody looks for on a
* charger. Anything not listed falls through to the raw key rather than being hidden,
* because an unknown socket is still worth seeing.
*/
/** The steps worth offering: a household socket, a fast AC post, and the two DC tiers. */
const KW_STEPS = [11, 22, 50, 150]
const SOCKET_LABEL: Record<string, string> = {
type2: 'Type 2',
type2_combo: 'CCS',
type2_cable: 'Type 2 cable',
ccs: 'CCS',
chademo: 'CHAdeMO',
type1: 'Type 1',
type1_combo: 'CCS1',
schuko: 'Schuko',
tesla_supercharger: 'Supercharger',
tesla_destination: 'Tesla Destination',
}
/** Label and icon per category, from the one table every road-trip surface reads. */
const CATEGORY_META = CORRIDOR_CATEGORY_BY_KEY
/** The small capitalised word over a group of controls, matching the rail's own captions. */
const EYEBROW = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
/** A card in this column: the same corner, hairline and surface the rail's cards use. */
const CARD = 'rounded-2xl border border-edge-faint bg-surface-card'
/**
* The tile in front of a result, in its category's own colour.
*
* Never the brand's logo. A corridor is mostly chains, and putting their marks here made
* a list of petrol stations read as an advertisement — while the ones with no logo on
* file fell back to a different picture entirely, so no two rows looked alike. The
* category's icon in its own tint is the same thing the rail draws on its dashed line and
* the map draws on the route, which is what makes a row, a pin and a stop recognisably
* one place.
*/
function ResultBadge({ category }: { category: string }): React.ReactElement {
const Icon = CATEGORY_META[category]?.Icon ?? MapPin
const color = serviceColor(category)
return (
<span
className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-[10px]"
// theme-lint-disable — the road-signage palette in `roadtripModel`, the same colour
// this kind of stop carries in the rail and on the map.
style={{ background: `${color}1f`, color }}
>
<Icon size={14} strokeWidth={1.9} aria-hidden />
</span>
)
}
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
return (
<li className="group flex items-center gap-3 rounded-xl py-1.5 pe-1.5 ps-1 transition-colors hover:bg-surface-hover">
<ResultBadge category={poi.category} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
{poi.name}
</div>
{/* Two facts, two elements. Both are translated phrases rather than short fixed
values, so they stay text and the gap does the separating a middot used to —
which also lets them wrap onto their own lines instead of breaking around a
dot left stranded at the end of a line. */}
<div
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
style={{ fontSize: FS.meta }}
>
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
<span>
{poi.alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
</span>
{/* What the charger offers, where OSM says. Socket names are proper nouns and
stay as they are; the numbers around them are what decides whether a car can
use it at all. A station that says nothing shows nothing rather than a row
of dashes, because "not stated" is not "no". */}
{poi.charging?.sockets.length ? (
<span className="flex flex-wrap items-baseline gap-x-1.5">
{poi.charging.sockets.slice(0, 3).map(s => (
<span key={s.type} className="text-content-muted">
{SOCKET_LABEL[s.type] ?? s.type}
{s.kw ? ` ${s.kw} kW` : ''}
{s.count && s.count > 1 ? ` ×${s.count}` : ''}
</span>
))}
</span>
) : null}
{poi.charging?.fee === false ? (
<span className="text-success">{t('roadtrip.poi.free')}</span>
) : null}
</div>
</div>
{/* Always there, quiet until the row is under the pointer: a button that only
exists on hover is one a keyboard user has to find by faith. */}
{onAdd ? (() => {
// One button, two meanings. Somewhere to sleep ends the day rather than
// interrupting the drive, so the icon and the label say that before the dialog
// opens instead of after.
const night = poi.category === 'hotel'
const label = night ? t('roadtrip.stay.nightAction') : t('roadtrip.poi.add')
const Icon = night ? BedDouble : Plus
return (
<Tooltip label={label}>
<button
type="button"
onClick={onAdd}
aria-label={label}
className="grid h-[26px] w-[26px] shrink-0 place-items-center rounded-lg text-content-faint transition-colors hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text focus-visible:outline-none"
>
<Icon size={15} strokeWidth={2.2} aria-hidden />
</button>
</Tooltip>
)
})() : null}
</li>
)
}
/** One category's hits, so a mixed search reads as several short lists instead of one long one. */
function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
category: string
pois: CorridorPoi[]
dayId: number | null
insertIndexFor: RoadtripCorridor['insertIndexFor']
onAddPoi: RoadtripCorridorPanelProps['onAddPoi']
}): React.ReactElement {
const { t } = useTranslation()
const meta = CATEGORY_META[category]
const Icon = meta?.Icon ?? MapPin
const color = serviceColor(category)
return (
<section className="pb-1">
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
// theme-lint-disable — see ResultBadge.
style={{ background: color, color: '#fff' }} // theme-lint-disable — road-signage palette
>
<Icon size={12} strokeWidth={2} aria-hidden />
</span>
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
{meta ? t(meta.labelKey) : category}
</span>
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
{t('roadtrip.poi.found', { count: pois.length })}
</span>
</header>
<ul>
{pois.map(poi => (
<ResultRow
key={poi.osm_id}
poi={poi}
// Added where it will be driven past, not at the end of the day.
onAdd={onAddPoi ? () => onAddPoi(poi, dayId, insertIndexFor(poi)) : undefined}
/>
))}
</ul>
</section>
)
}
/**
* "What is on the way" — the right column while road trip mode is on.
*
* Searching is a button, not a side effect of panning: one run is several Overpass
* requests and the mirrors are shared infrastructure. The scope is one day's drive, which
* keeps a run to a handful of boxes and makes "add this" unambiguous — the stop lands on
* the day whose route it was found along. Hits are drawn on the map at the same time, so
* 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.
*/
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const { search } = corridor
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)
// Something was found, the filter just hides it — a different state from "not searched
// yet" and from "the drive really has none of these".
/**
* The socket families this search actually turned up, in the order the table lists
* them. Offering all ten would put CHAdeMO in the dropdown for a corridor that has
* three Type 2 posts on it, which is a filter that can only produce an empty list.
*/
const socketsFound = useMemo(() => {
const seen = new Set<string>()
for (const p of search.results) for (const s of p.charging?.sockets ?? []) seen.add(s.type)
// Compared explicitly: the default sort is by UTF-16 code unit, which is not the
// order anybody reads a list of plug names in.
return [...seen].sort((a, b) => a.localeCompare(b))
}, [search.results])
const hasCharging = socketsFound.length > 0
const filteredToNothing = search.results.length > 0 && corridor.visible.length === 0
// The day has to have routed. Until it does, the corridor is built from the
// straight line between the stops, so the boxes march across whatever lies
// between them rather than along the roads actually driven — and when the
// routing answer lands a second later the line changes, which clears the
// search mid-flight and drops every result with no error and no explanation.
// The routing runs one day at a time, about a second apart, so on a long trip
// that window is wide open for the day somebody picks.
const dayRouted = (corridor.day?.geometry.length ?? 0) > 1
const canSearch = !search.loading
&& !routes.loading
&& dayRouted
&& corridor.categories.length > 0
&& (corridor.day?.stops.length ?? 0) > 1
const progressPct = search.progress.total
? Math.round((search.progress.done / search.progress.total) * 100)
: 0
const warnings: [string, string][] = []
if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
// Some boxes answered and some did not. Saying so is the difference between "there is
// no fuel on this stretch" and "nobody looked at this stretch".
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
// The stretch was searched and the answer came back short. Without this line a filter
// finding nothing looks like proof there is nothing.
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
return (
<div 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. */}
<div className="flex flex-shrink-0 items-center gap-2.5 px-1">
<h2 className="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}
</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>
<span className="h-px bg-edge-faint" aria-hidden />
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.within')}</span>
<div className="flex gap-1 rounded-xl bg-surface-tertiary p-1">
{CORRIDOR_WIDTHS_KM.map(km => (
<button
key={km}
type="button"
aria-pressed={corridor.widthKm === km}
onClick={() => corridor.setWidthKm(km)}
style={{ fontSize: FS.control }}
className={`flex h-[26px] flex-1 items-center justify-center rounded-lg tabular-nums transition-colors ${
corridor.widthKm === km
? 'bg-surface-card font-semibold text-content shadow-card'
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
}`}
>
{formatDistance(km, distanceUnit)}
</button>
))}
</div>
</div>
{/* One word, so it can be read at the size a primary action deserves. The panel
it sits in is headed "along the route" and every control above it narrows the
same search; repeating that on the button only made it small. */}
<button
type="button"
onClick={search.search}
disabled={!canSearch}
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={15} className="animate-spin" aria-hidden />
: <Search size={15} strokeWidth={2} aria-hidden />}
{search.loading
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
: t('roadtrip.poi.search')}
</button>
</div>
{/* How the run is getting on — a row of its own while it lasts, with the share
done as a figure rather than only as a bar: a corridor search is many requests
and "58%" answers "is this worth waiting for" that a bar only hints at. */}
{search.loading ? (
<div
className="flex h-[32px] flex-shrink-0 items-center gap-2 rounded-xl border border-edge bg-surface-card pe-1.5 ps-3 text-content-muted"
role="progressbar"
aria-valuenow={progressPct}
aria-valuemin={0}
aria-valuemax={100}
>
<RotateCw size={13} className="animate-spin" aria-hidden />
<span className="min-w-0 truncate font-medium" style={{ fontSize: FS.control }}>
{t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })}
</span>
<span
className="ms-auto inline-flex h-[22px] shrink-0 items-center rounded-full bg-inverse px-2 font-geist font-semibold tabular-nums tracking-[0.08em] text-inverse-text"
style={{ fontSize: FS.micro }}
>
{`${progressPct}%`}
</span>
</div>
) : null}
{warnings.length > 0 ? (
<div className="flex flex-shrink-0 flex-col gap-1.5">
{warnings.map(([key, text]) => (
<p
key={key}
className={`flex items-start gap-1.5 ${key === 'failed' ? 'text-danger' : 'text-warning'}`}
style={{ fontSize: FS.meta }}
>
<AlertTriangle size={12} className="mt-px shrink-0" aria-hidden />
{text}
</p>
))}
</div>
) : null}
{/* What was found. */}
<div className={`flex min-h-0 flex-1 flex-col overflow-hidden ${CARD}`}>
{/* Narrowing what was found. Deliberately not called "search": the button above
asks the place search for new results, this only hides rows already in. It
appears once there is something to narrow. */}
{search.results.length > 0 ? (
<div className="flex-shrink-0 border-b border-edge-faint p-3">
<div className="relative">
<Search size={14} strokeWidth={1.9} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden />
<input
type="text"
value={corridor.nameFilter}
onChange={e => corridor.setNameFilter(e.target.value)}
placeholder={t('roadtrip.poi.filter')}
aria-label={t('roadtrip.poi.filter')}
style={{ fontSize: FS.time }}
className="h-[32px] w-full rounded-[10px] bg-surface-tertiary pe-8 ps-9 text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-accent"
/>
{corridor.nameFilter ? (
<button
type="button"
onClick={() => corridor.setNameFilter('')}
aria-label={t('common.clear')}
className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={13} aria-hidden />
</button>
) : null}
</div>
{/* Around one point of the drive, which is how a break is actually planned: you
decide roughly where to stop and then look at what is there, rather than
reading seventy hits spread over seven hundred kilometres. The midpoint of
a leg is offered alongside the stops, because a break on a five-hour drive
belongs in the middle of it and not at either end.
The minWidth: 0 is what makes the name shorten instead of pushing the
kilometre control off the panel: a nowrap label reports its whole width as
its min-content contribution, so a flex item at its default min-width of
auto refuses to go below it however long the place name is. */}
{corridor.anchors.length > 1 && corridor.day ? (
<div className="mt-2 flex gap-2">
<CustomSelect
value={corridor.section ? `${corridor.section.kind}:${corridor.section.index}` : ''}
onChange={value => {
const raw = String(value)
if (!raw) { corridor.setSection(null); return }
const [kind, index] = raw.split(':')
corridor.setSection({
dayId: corridor.day!.dayId,
kind: kind as 'stop' | 'leg',
index: Number(index),
})
}}
options={[
{ value: '', label: t('roadtrip.poi.wholeDay') },
...corridor.anchors.map(a => ({
value: `${a.kind}:${a.index}`,
label: a.kind === 'stop'
? (corridor.day!.stops[a.index]?.name ?? t('roadtrip.poi.wholeDay'))
: t('roadtrip.poi.midLeg', {
from: corridor.day!.stops[a.index]?.name ?? '',
to: corridor.day!.stops[a.index + 1]?.name ?? '',
}),
})),
]}
style={{ flex: 1, minWidth: 0 }}
size="sm"
menuFit="content"
/>
{corridor.section ? (
<CustomSelect
value={String(corridor.sectionKm)}
onChange={value => corridor.setSectionKm(Number(value))}
options={CORRIDOR_SECTION_KM.map(km => ({
value: String(km),
label: `± ${formatDistance(km, distanceUnit)}`,
}))}
style={{ width: 110, flexShrink: 0 }}
size="sm"
/>
) : null}
</div>
) : null}
{/* Only where there is a charger to narrow. Two answers OSM actually carries
often enough to filter on: which plug, and how fast. A station that states
neither stays in the list, because roughly two thirds of them state no
power at all and reading that silence as "too slow" would empty the map. */}
{hasCharging ? (
<div className="mt-2 flex gap-2">
<CustomSelect
value={corridor.socketFilter}
onChange={value => corridor.setSocketFilter(String(value))}
options={[
{ value: '', label: t('roadtrip.poi.anySocket') },
...socketsFound.map(s => ({ value: s, label: SOCKET_LABEL[s] ?? s })),
]}
style={{ flex: 1, minWidth: 0 }}
size="sm"
/>
<CustomSelect
value={String(corridor.minKw)}
onChange={value => corridor.setMinKw(Number(value))}
options={[
{ value: '0', label: t('roadtrip.poi.anyPower') },
...KW_STEPS.map(kw => ({ value: String(kw), label: `${kw}+ kW` })),
]}
style={{ flex: 1, minWidth: 0 }}
size="sm"
/>
</div>
) : null}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
{grouped.length === 0 ? (
// The same empty state the rest of TREK uses, with the mascot acting out the
// scene. `search` while it is running, `idle` before anyone has asked, and a
// confused look when a filter left nothing standing — the picture says which
// of the three it is before the sentence is read.
<EmptyState
scene={search.loading ? 'search' : 'idle'}
mood={filteredToNothing ? 'confused' : undefined}
size={88}
compact
surface="var(--bg-secondary)"
title={
search.loading
? t('roadtrip.poi.searchingHint')
: filteredToNothing
// Four controls narrow this list and only one of them is the
// name box. Blaming it regardless produced `Nothing on the
// way matches ""` — empty quotes naming a filter the reader
// never set — when a section, a plug type or a minimum power
// was what emptied it.
? corridor.nameFilter.trim()
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
: t('roadtrip.poi.noneMatchFilters')
: t('roadtrip.poi.empty')
}
/>
) : (
<>
{/* Shown whenever anything narrowed the list, not only the name
box: a section or a plug filter hides hits just as much, and
the reader is owed the same "x of y" either way. */}
{corridor.visible.length !== search.results.length ? (
<p className="px-2 pt-2.5 text-content-faint" style={{ fontSize: FS.meta }}>
{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}
</p>
) : null}
{grouped.map(g => (
<ResultGroup
key={g.key}
category={g.key}
pois={g.pois}
dayId={corridor.day?.dayId ?? null}
insertIndexFor={corridor.insertIndexFor}
onAddPoi={onAddPoi}
/>
))}
</>
)}
</div>
</div>
</div>
)
}
@@ -1,130 +0,0 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripLimitsCard from './RoadtripLimitsCard'
/**
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
*
* The one that matters is when a number is written. These fields had no local
* state, so every keystroke was a settings PUT: typing "180" sent three, and
* they raced. The value that decides whether a day is over budget could end up a
* tenth of what was typed and only say so after the next reload.
*/
function open(onSave?: (key: string, value: number) => void) {
render(<RoadtripLimitsCard onSave={onSave} />)
fireEvent.click(screen.getByRole('button'))
}
/** The three inputs, in the order the dialog lists them. */
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
beforeEach(() => {
useSettingsStore.setState({
settings: {
roadtrip_leg_minutes: 0,
roadtrip_day_minutes: 0,
roadtrip_range_km: 0,
distance_unit: 'metric',
} as never,
})
})
describe('RoadtripLimitsCard', () => {
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '1' } })
fireEvent.change(leg, { target: { value: '18' } })
fireEvent.change(leg, { target: { value: '180' } })
// Three keystrokes used to be three unordered PUTs carrying 1, 18 and 180.
expect(onSave).not.toHaveBeenCalled()
expect(leg.value).toBe('180')
})
it('FE-ROADTRIP-LIMITS-002: leaving the field saves once, with what was typed', () => {
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '180' } })
fireEvent.blur(leg)
expect(onSave).toHaveBeenCalledTimes(1)
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 180)
})
it('FE-ROADTRIP-LIMITS-003: Enter commits without having to click away', () => {
const onSave = vi.fn()
open(onSave)
const [, day] = inputs()
fireEvent.change(day, { target: { value: '540' } })
fireEvent.keyDown(day, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('roadtrip_day_minutes', 540)
})
it('FE-ROADTRIP-LIMITS-004: leaving a field nobody touched saves nothing', () => {
const onSave = vi.fn()
open(onSave)
fireEvent.blur(inputs()[0])
expect(onSave).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-LIMITS-005: retyping the value it already had is not a write', () => {
useSettingsStore.setState({
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '180' } })
fireEvent.blur(leg)
expect(onSave).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-LIMITS-006: clearing a field turns the limit off', () => {
useSettingsStore.setState({
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '' } })
fireEvent.blur(leg)
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 0)
})
it('FE-ROADTRIP-LIMITS-007: the range field carries the reader own unit', () => {
useSettingsStore.setState({
settings: { roadtrip_leg_minutes: 0, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'imperial' } as never,
})
open(vi.fn())
expect(screen.getByText('mi')).toBeInTheDocument()
expect(screen.queryByText('km')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => {
// `onSave` absent is how the caller says the reader may look but not change.
open(undefined)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '180' } })
// Nothing to assert but the absence of a crash: the commit path has no
// handler to call, and must not assume one.
expect(() => fireEvent.blur(leg)).not.toThrow()
})
})
@@ -1,201 +0,0 @@
import React, { useState } from 'react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { convertDistance, formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { DistanceUnit } from '../../types'
import { FS } from './typeScale'
/**
* The three numbers that decide when the rail speaks up about the driving.
*
* Behind a button rather than laid out in the column, because this is set once and then
* read never: a permanent card costs three rows of a narrow sidebar for something most
* travellers touch on the first day of planning and leave alone after. The button carries
* the current answer, so what is set stays visible without the form being.
*
* All three are personal rather than instance configuration: how long somebody is willing
* to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
* They are plain per-user settings, which is why this needs no migration.
*
* Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
* idea what car this is, and neither has TREK, so the range is a number the traveller
* types. Furkot does exactly the same, for the same reason.
*/
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
function parseLimit(raw: string): number {
const n = Number(raw.replace(',', '.'))
return Number.isFinite(n) && n > 0 ? n : 0
}
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
icon: typeof Clock
label: string
suffix: string
value: number | undefined
placeholder: string
onChange: (next: number) => void
}): React.ReactElement {
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
// settings PUT per character — typing "180" produced three, carrying 1, 18 and
// 180, unordered and concurrent over HTTP/2. Whichever the server committed
// last won, so the limit that decides every over-budget warning could quietly
// end up a tenth of what was typed, and only show it after the next reload.
const [draft, setDraft] = useState<string | null>(null)
const shown = draft ?? (value ? String(value) : '')
const commit = () => {
if (draft === null) return
const next = parseLimit(draft)
setDraft(null)
if (next !== (value ?? 0)) onChange(next)
}
return (
<label className="flex items-center gap-3">
<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>
<input
type="number"
inputMode="numeric"
min={0}
value={shown}
placeholder={placeholder}
onChange={e => setDraft(e.target.value)}
onBlur={commit}
// Committed here rather than by blurring and letting onBlur do it: a
// second commit is a no-op anyway, and going through blur made Enter
// depend on focus handling instead of on the key.
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
/>
<span className="w-7 text-caption text-content-faint">{suffix}</span>
</label>
)
}
export default function RoadtripLimitsCard({ onSave }: {
/** Persists one setting. Absent leaves the dialog read-only. */
onSave?: (key: string, value: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const settings = useSettingsStore(s => s.settings)
const [open, setOpen] = useState(false)
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
const rangeKm = settings.roadtrip_range_km
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
// third too early for ever after.
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
const setRange = (shown: number) => {
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
onSave?.('roadtrip_range_km', Math.round(km))
}
// What is set, on the button itself, so the form does not have to be open to read it.
// Each one keeps its own icon, because three numbers in a row say nothing about which
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
// an imperial traveller reads miles here and types miles in the dialog, while what is
// stored stays kilometres either way.
const badges = [
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
return (
<>
{/* Full width and card-sized, the same weight as the search controls above it: this
is one of three things this column does, not a footnote under the other two.
The current answer rides on the button, so what is set stays visible without the
form being open. */}
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{/* No icon tile on the trigger: the three badges below already carry a clock, a
calendar and a pump, and a fourth mark in front of the title only ate the width
they need to sit on one line. */}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-body font-semibold text-content">
{t('roadtrip.limit.title')}
</span>
{badges.length ? (
<span className="flex flex-wrap items-center gap-1">
{badges.map(({ key, Icon, text }) => (
<span
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>
))}
</span>
) : (
<span className="truncate text-content-faint" style={{ fontSize: FS.label }}>
{t('roadtrip.limit.none')}
</span>
)}
</span>
<ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />
</button>
{open ? (
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
<span className="flex items-center gap-2">
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.limit.title')}
</span>
}>
<div className="flex flex-col gap-4">
<LimitRow
icon={Clock}
label={t('roadtrip.limit.legLabel')}
suffix={t('roadtrip.limit.minutes')}
value={legMinutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
/>
<LimitRow
icon={CalendarClock}
label={t('roadtrip.limit.dayLabel')}
suffix={t('roadtrip.limit.minutes')}
value={dayMinutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
<LimitRow
icon={Fuel}
label={t('roadtrip.limit.rangeLabel')}
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
onChange={setRange}
/>
{/* Said out loud rather than left to be discovered: the day figure this is
measured against is driving time only. It does not include how long anyone
stands still, and it does not include the leg to the hotel, because the
rail does not model one. A limit called "travel time per day" over that
number would be a lie that looks tidy. */}
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
</div>
</Modal>
) : null}
</>
)
}
@@ -1,52 +0,0 @@
import React from 'react'
import { CalendarDays, Route } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
interface RoadtripModeSwitchProps {
active: boolean
onChange: (roadtrip: boolean) => void
}
/**
* Switches the plan view's left rail between the day plan and the road trip reading of
* the same trip. Only rendered while the road trip addon is on, so a trip that isn't
* driven anywhere never grows a control it has no use for.
*/
export default function RoadtripModeSwitch({ active, onChange }: RoadtripModeSwitchProps): React.ReactElement {
const { t } = useTranslation()
const options: [boolean, string, typeof Route][] = [
[false, t('roadtrip.mode.days'), CalendarDays],
[true, t('roadtrip.mode.roadtrip'), Route],
]
return (
<div
role="tablist"
aria-label={t('roadtrip.mode.label')}
className="mx-3.5 mb-0 mt-3 flex gap-1 rounded-xl border border-edge-faint bg-surface-tertiary p-1"
>
{options.map(([value, label, Icon]) => {
const selected = active === value
return (
<button
key={label}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(value)}
// Sized off the day plan's own type, like the road trip rail below it: the
// switcher sits above both, so it cannot be the largest thing in the column.
style={{ fontSize: 'calc(11.5px * var(--fs-scale-body, 1))' }}
className={`flex h-[30px] flex-1 items-center justify-center gap-1.5 rounded-lg px-2 transition-colors ${
selected
? 'bg-surface-card font-semibold text-content shadow-card'
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
}`}
>
<Icon size={13} strokeWidth={1.8} aria-hidden />
<span className="truncate">{label}</span>
</button>
)
})}
</div>
)
}
@@ -1,559 +0,0 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripSidebar from './RoadtripSidebar'
import RoadtripModeSwitch from './RoadtripModeSwitch'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
return {
placeId: over.assignmentId * 10,
lat: 53.5,
lng: 9.9,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
const leg = (over: Partial<RouteSegment> = {}): RouteSegment =>
({ distance: 100000, duration: 3600, distanceText: '100 km', durationText: '1 h', mode: 'driving', ...over }) as RouteSegment
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
const stops = over.stops ?? [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
return {
dayId: 1,
dayNumber: 1,
date: null,
title: null,
stops,
legs: [leg()],
legVias: [], driveWarnings: [], dayWarning: null,
schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
geometry: [],
distance: 100000,
duration: 3600,
...over,
}
}
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
return {
days: [day()],
lines: [],
segments: [],
accessLines: [],
vias: [],
totalDistance: 100000,
totalDuration: 3600,
totalStops: 2,
quietDays: [],
loading: false,
...over,
}
}
describe('RoadtripSidebar', () => {
/**
* The value the summary head pairs with this label. The same words also name the chips
* further down for screen readers, so the lookup has to hold on to the `dt` — otherwise
* it matches "Distance" three times over.
*/
const total = (label: string): string => {
// The summary is three columns; the caption and its number are siblings in one cell.
// The same words also name the badges further down, so the lookup keeps to the cell
// whose first child is the caption itself.
const caption = screen.getAllByText(label).find(el => el.parentElement?.firstElementChild === el)!
return caption.parentElement!.lastElementChild!.textContent!.replace(/\s+/g, ' ').trim()
}
it('FE-ROADTRIP-SIDEBAR-001: leads with the totals for the whole drive', () => {
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')
expect(total('Stops')).toBe('5')
})
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.
wrap(<RoadtripSidebar routes={routes({
days: [day({ distance: 250000, duration: 9000 })],
totalDistance: 250000,
totalDuration: 9000,
})} />)
expect(screen.getByText('Hamburg')).toBeInTheDocument()
expect(screen.getByText('Berlin')).toBeInTheDocument()
// One sentence, not two glued values: the middot is gone and so is the pair of chips.
expect(screen.getByText('100 km in 1 h')).toBeInTheDocument()
expect(screen.queryByText('100 km · 1 h')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-003: says a leg is still coming rather than showing a blank', () => {
wrap(<RoadtripSidebar routes={routes({
days: [day({ legs: [undefined], distance: 0, duration: 0 })],
totalDistance: 0,
totalDuration: 0,
})} />)
// An absent value says so rather than reading zero.
expect(screen.queryByText(/100 km in/)).not.toBeInTheDocument()
expect(screen.getByText('No route')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-004: sets a pinned arrival apart from a computed one', () => {
const stops = [stop({ assignmentId: 1, name: 'Ferry' }), stop({ assignmentId: 2, name: 'Berlin' })]
const schedule = {
entries: [
{ arrival: '09:00', departure: '09:30', anchored: true, dayOffset: 0 },
{ arrival: '10:30', departure: '10:30', anchored: false, dayOffset: 0 },
],
warnings: [],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
// Both are plain text at the row's edge — no pill, no icon. What separates the time
// somebody chose from the one the drive worked out is weight and ink. The reason is
// named in a tooltip, which only enters the DOM on hover, so the visible difference
// is what this pins.
const pinned = screen.getByText('09:00')
const computed = screen.getByText('10:30')
expect(pinned.className).toContain('font-semibold')
expect(computed.className).not.toContain('font-semibold')
})
it('FE-ROADTRIP-SIDEBAR-005: flags a stop the drive cannot reach in time', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
const schedule = {
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 })),
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-006: shows how long a stop is planned to take', () => {
const stops = [stop({ assignmentId: 1, name: 'Museum', dwellMinutes: 90 }), stop({ assignmentId: 2, name: 'Berlin' })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-007: selecting a stop reports the place and the assignment', () => {
const onSelectStop = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onSelectStop={onSelectStop} />)
fireEvent.click(screen.getByText('Berlin'))
expect(onSelectStop).toHaveBeenCalledWith(20, 2)
})
it('FE-ROADTRIP-SIDEBAR-008: marks the selected stop for assistive tech', () => {
wrap(<RoadtripSidebar routes={routes()} selectedAssignmentId={2} />)
const selected = screen.getByText('Berlin').closest('button')!
expect(selected).toHaveAttribute('aria-current', 'true')
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
})
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
const second = day({ dayId: 2, dayNumber: 2, distance: 50000, duration: 1800, stops: [
stop({ assignmentId: 3, name: 'Dresden' }),
stop({ assignmentId: 4, name: 'Prague' }),
] })
wrap(<RoadtripSidebar routes={routes({ days: [day(), second], totalStops: 4 })} />)
const headings = screen.getAllByRole('heading', { level: 3 })
expect(headings).toHaveLength(2)
expect(within(headings[1].parentElement!).getByText(/50 km/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-011: draws the midnight crossing where it happens', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
const schedule = {
entries: [
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
{ arrival: '01:30', departure: '01:30', anchored: false, dayOffset: 1 },
],
warnings: [{ index: 1, code: 'overnight' as const }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
// Said twice on purpose: once as the break in the chain, once on the arrival after
// it, where "01:30" would otherwise read as tonight.
expect(screen.getAllByText('Next day')).toHaveLength(2)
// The day marker rides on the arrival that crossed midnight, where "01:30" would
// otherwise read as tonight.
expect(screen.getByText('01:30').textContent).toContain('+1')
})
it('FE-ROADTRIP-SIDEBAR-012: a late arrival past midnight keeps both findings', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
const schedule = {
entries: [
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
{ arrival: '01:30', departure: '01:30', anchored: true, dayOffset: 1 },
],
// Both belong to the same stop. Reading only the first one dropped the late flag.
warnings: [
{ index: 1, code: 'overnight' as const },
{ index: 1, code: 'late' as const, minutes: 45 },
],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
expect(screen.getAllByText('Next day')).toHaveLength(2)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-013: says the totals are still a partial sum while legs arrive', () => {
const { unmount } = wrap(<RoadtripSidebar routes={routes({ loading: true })} />)
expect(screen.getByText('Still working out the rest of the drive')).toBeInTheDocument()
unmount()
wrap(<RoadtripSidebar routes={routes({ loading: false })} />)
expect(screen.queryByText('Still working out the rest of the drive')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-014: shows the name a day was given', () => {
wrap(<RoadtripSidebar routes={routes({ days: [day({ title: 'Along the coast' })] })} />)
expect(screen.getByText('Along the coast')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-015: dates the day machine-readably, whatever the locale prints', () => {
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ date: '2026-08-31' })] })} />)
const stamp = container.querySelector('time')!
expect(stamp).toHaveAttribute('datetime', '2026-08-31')
expect(stamp.textContent).not.toBe('')
})
it('FE-ROADTRIP-SIDEBAR-016: a leg a plugin routed carries its own mark and its note', () => {
const charged = leg({ mode: 'plugin:charge', noteText: '25 min charge' })
wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [charged] })] })} />)
// Free text, so it gets a line rather than being squeezed into a pill.
expect(screen.getByText('25 min charge')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-019: a fuel stop breaks the drive instead of taking a number', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
stop({ assignmentId: 3, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
// The kind is what you look for on a drive; the running number is on the map pin.
expect(screen.getByLabelText('Fuel')).toBeInTheDocument()
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
// How long the pause takes is what the row is for.
expect(screen.getByText('15 min')).toBeInTheDocument()
// The two places the trip is actually for keep counting one, two — the tank stop
// between them is part of the drive, not a third destination. Scoped to the chain:
// the summary above counts stops too, in the same digits.
const chain = screen.getByRole('list')
expect(within(chain).getByText('1')).toBeInTheDocument()
expect(within(chain).getByText('2')).toBeInTheDocument()
expect(within(chain).queryByText('3')).not.toBeInTheDocument()
// Two drive bands, because the stop splits the leg it falls on. Scoped to the chain
// again: the day's header badge phrases its own total the same way.
expect(within(chain).getAllByText('100 km in 1 h')).toHaveLength(2)
})
it('FE-ROADTRIP-SIDEBAR-032: every stop offers a stay, whether it has one or not', () => {
// The value has never been editable anywhere in TREK, so a stop without one needs a
// way in before it can get one — the plus sits in the slot the number will occupy.
const onEditStay = vi.fn()
const stops = [
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onEditStay={onEditStay} />)
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
const chain = screen.getByRole('list')
const add = within(chain).getByText('+')
fireEvent.click(add)
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
})
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
// A label, not an invitation that leads nowhere.
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
expect(screen.queryByText('+')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-030: a day counts the places it visits, not the pauses on the way', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Ionity Horst', stopType: 'charging' }),
stop({ assignmentId: 3, name: 'Rasthof Fläming', stopType: 'rest_area' }),
stop({ assignmentId: 4, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg()] })] })} />)
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-031: every kind the corridor finds is a pause, not a destination', () => {
// Anything picked off "along the route" was come across on the way, so all six kinds
// sit inside the leg with their own icon and none of them counts as a stop.
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Camping Seeblick', stopType: 'campsite' }),
stop({ assignmentId: 3, name: 'Bäckerei Junge', stopType: 'restaurant' }),
stop({ assignmentId: 4, name: 'Holstentor', stopType: 'sights' }),
stop({ assignmentId: 5, name: 'Lübeck' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg(), leg()] })] })} />)
expect(screen.getByLabelText('Campsite')).toBeInTheDocument()
expect(screen.getByLabelText('Food')).toBeInTheDocument()
expect(screen.getByLabelText('Sights')).toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-020: a stop kind nobody knows falls back to the running number', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Etwas Neues', stopType: 'helipad' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
expect(within(screen.getByRole('list')).getByText('2')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-021: without the right to edit days the chain cannot be dragged', () => {
const { container } = wrap(<RoadtripSidebar routes={routes()} />)
expect(container.querySelector('li[draggable="true"]')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-022: dropping a stop on another reports where it should go', () => {
const onReorderStop = vi.fn()
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Lueneburg' }),
stop({ assignmentId: 3, name: 'Berlin' }),
]
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />,
)
const rows = container.querySelectorAll('li[draggable="true"]')
expect(rows).toHaveLength(3)
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
fireEvent.dragStart(rows[2], { dataTransfer })
fireEvent.dragOver(rows[0])
fireEvent.drop(rows[0])
// The last stop dropped on the first: day, the assignment moved, and its new index.
expect(onReorderStop).toHaveBeenCalledWith(1, 3, 0)
})
it('FE-ROADTRIP-SIDEBAR-023: dropping a stop back on itself changes nothing', () => {
const onReorderStop = vi.fn()
const { container } = wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
const rows = container.querySelectorAll('li[draggable="true"]')
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
fireEvent.dragStart(rows[0], { dataTransfer })
fireEvent.dragOver(rows[0])
fireEvent.drop(rows[0])
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-024: the chain can be reordered from the keyboard too', () => {
const onReorderStop = vi.fn()
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Lueneburg' }),
stop({ assignmentId: 3, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />)
// Dragging is a gesture; without this the chain is unreachable without a mouse.
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 2)
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowUp', altKey: true })
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 0)
})
it('FE-ROADTRIP-SIDEBAR-025: the ends of a day cannot be pushed past themselves', () => {
const onReorderStop = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowUp', altKey: true })
fireEvent.keyDown(screen.getByText('Berlin').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-026: an arrow without Alt still belongs to the list, not to us', () => {
const onReorderStop = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowDown' })
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-027: a day without a drive appears only while something is dragged', () => {
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
const onMoveStopToDay = vi.fn()
const { container } = wrap(
<RoadtripSidebar
routes={routes({ quietDays: [quiet] })}
onReorderStop={vi.fn()}
onMoveStopToDay={onMoveStopToDay}
/>,
)
// Nothing in flight: an empty day is not worth a row, the rail is about the drive.
expect(screen.queryByText(/drop one here/i)).not.toBeInTheDocument()
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
expect(screen.getByText(/drop one here/i)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-028: dropping on another day reports both days', () => {
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
const onMoveStopToDay = vi.fn()
const { container } = wrap(
<RoadtripSidebar
routes={routes({ quietDays: [quiet] })}
onReorderStop={vi.fn()}
onMoveStopToDay={onMoveStopToDay}
/>,
)
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
const target = screen.getByText(/drop one here/i).closest('section')!
fireEvent.dragOver(target)
fireEvent.drop(target)
// From day 1, the second stop (assignment 2), onto day 9, at its end.
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 9, 0)
})
it('FE-ROADTRIP-SIDEBAR-029: a stop dropped on a row of another day lands at that row', () => {
const second = day({
dayId: 2,
dayNumber: 2,
stops: [stop({ assignmentId: 3, name: 'Dresden' }), stop({ assignmentId: 4, name: 'Prague' })],
})
const onMoveStopToDay = vi.fn()
const onReorderStop = vi.fn()
const { container } = wrap(
<RoadtripSidebar
routes={routes({ days: [day(), second], totalStops: 4 })}
onReorderStop={onReorderStop}
onMoveStopToDay={onMoveStopToDay}
/>,
)
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
fireEvent.dragOver(rows[3])
fireEvent.drop(rows[3])
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 1, 2, 1)
// Crossing days is a different call; the same-day reorder must not also fire.
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] })] })} />)
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
const schedule = {
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 })),
warnings: [{ index: 1, code: 'late' as const }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
expect(screen.getByLabelText(/0 min/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-018: the track badge is a viewer-free control, and it says when a day is shaped', () => {
const onFollowTrack = vi.fn()
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
const { rerender } = wrap(
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} viaCounts={{ 1: 4 }} />,
)
const badge = screen.getByRole('button', { name: 'Track' })
fireEvent.click(badge)
expect(onFollowTrack).toHaveBeenCalledWith(1)
// The rail draws no vias, so the tint is the only place a drive shaped by hand
// differs from one the router picked on its own.
expect(badge.className).toContain('bg-accent-subtle')
rerender(
<TranslationProvider>
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} />
</TranslationProvider>,
)
expect(screen.getByRole('button', { name: 'Track' }).className).not.toContain('bg-accent-subtle')
})
it('FE-ROADTRIP-SIDEBAR-019: a viewer is offered no track badge at all', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
})
})
describe('RoadtripModeSwitch', () => {
it('FE-ROADTRIP-MODESWITCH-001: offers both readings and marks the active one', () => {
wrap(<RoadtripModeSwitch active onChange={vi.fn()} />)
const tabs = screen.getAllByRole('tab')
expect(tabs).toHaveLength(2)
expect(tabs[0]).toHaveAttribute('aria-selected', 'false')
expect(tabs[1]).toHaveAttribute('aria-selected', 'true')
})
it('FE-ROADTRIP-MODESWITCH-002: switching back to the day plan reports false', () => {
const onChange = vi.fn()
wrap(<RoadtripModeSwitch active onChange={onChange} />)
fireEvent.click(screen.getAllByRole('tab')[0])
expect(onChange).toHaveBeenCalledWith(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,143 +0,0 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
/**
* FE-STAYMODAL-001..011 — how long the traveller stays at one stop.
*
* The value is `places.duration_minutes`, which the schedule has read for years
* and nothing could write. Two things are worth pinning: that clearing sends a
* zero rather than a null — the update statement folds a null into "leave it
* alone", so a null could give a stop a stay but never take one away — and that
* the dialog answers the question the number is chosen for, which is when the
* drive leaves again.
*/
const draft = (over: Partial<StayDraft> = {}): StayDraft => ({
placeId: 9,
name: 'Rasthof Dammer Berge',
minutes: null,
arrival: '12:00',
...over,
})
function open(over: Partial<StayDraft> = {}, onSave = vi.fn(), onClose = vi.fn()) {
render(<RoadtripStayModal stop={draft(over)} onClose={onClose} onSave={onSave} />)
return { onSave, onClose }
}
const slider = () => screen.getByRole('slider') as HTMLInputElement
beforeEach(() => {
useSettingsStore.setState({ settings: { time_format: '24h' } as never })
})
describe('RoadtripStayModal', () => {
it('FE-STAYMODAL-001: no stop means no dialog', () => {
const { container } = render(<RoadtripStayModal stop={null} onClose={vi.fn()} onSave={vi.fn()} />)
expect(container).toBeEmptyDOMElement()
})
it('FE-STAYMODAL-002: it opens on the stop own length, not on the last one looked at', () => {
open({ minutes: 45 })
expect(slider().value).toBe('45')
})
it('FE-STAYMODAL-003: a stop with no stay opens at nothing rather than at a guess', () => {
open({ minutes: null })
expect(slider().value).toBe('0')
expect(screen.getAllByText(/no stay|none/i).length).toBeGreaterThan(0)
})
it('FE-STAYMODAL-004: it answers the question the number is chosen for', () => {
// The arrival is fixed by the drive; the departure is the only thing this
// dialog moves, and seeing it move is the point of a slider over a box.
open({ minutes: 30, arrival: '12:00' })
expect(screen.getByText('12:00')).toBeInTheDocument()
expect(screen.getByText('12:30')).toBeInTheDocument()
fireEvent.change(slider(), { target: { value: '90' } })
expect(screen.getByText('13:30')).toBeInTheDocument()
})
it('FE-STAYMODAL-005: a stop the drive has not timed shows no clock at all', () => {
// Better than inventing one: a departure without an arrival is a number with
// nothing behind it.
open({ minutes: 30, arrival: null })
expect(screen.queryByText(/\d\d:\d\d/)).not.toBeInTheDocument()
})
it('FE-STAYMODAL-006: the clock follows the reader own format', () => {
useSettingsStore.setState({ settings: { time_format: '12h' } as never })
open({ minutes: 60, arrival: '13:00' })
expect(screen.getByText(/1:00\s*PM/i)).toBeInTheDocument()
expect(screen.getByText(/2:00\s*PM/i)).toBeInTheDocument()
})
it('FE-STAYMODAL-007: the two buttons nudge, and stop at the ends', () => {
open({ minutes: 0 })
// Nothing below zero: the minus is off rather than silently doing nothing.
expect(screen.getByRole('button', { name: '-5' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '+5' }))
expect(slider().value).toBe('5')
fireEvent.click(screen.getByRole('button', { name: '-5' }))
expect(slider().value).toBe('0')
})
it('FE-STAYMODAL-008: the presets are one tap, which is the point of them', () => {
open({ minutes: 0 })
fireEvent.click(screen.getByRole('button', { name: '1 h' }))
expect(slider().value).toBe('60')
})
it('FE-STAYMODAL-009: saving reports the stop and the chosen length, then closes', async () => {
const { onSave, onClose } = open({ minutes: 0 })
fireEvent.change(slider(), { target: { value: '45' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 45))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('FE-STAYMODAL-010: clearing sends a zero, never a null', async () => {
// The update statement folds a null into "leave it alone", so a null could
// give a stop a stay and never take one away.
const { onSave } = open({ minutes: 90 })
fireEvent.click(screen.getByRole('button', { name: /no stay|none/i }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 0))
expect(onSave.mock.calls[0][1]).not.toBeNull()
})
it('FE-STAYMODAL-011: a save already running is not started twice', async () => {
// Two clicks on Save wrote the same value twice and closed on the first
// answer, which on a slow connection is easy to do.
let release: () => void = () => {}
const onSave = vi.fn(() => new Promise<void>(r => { release = r }))
open({ minutes: 30 }, onSave as never)
const save = screen.getByRole('button', { name: /^save$/i })
fireEvent.click(save)
fireEvent.click(save)
expect(onSave).toHaveBeenCalledTimes(1)
release()
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1))
})
it('FE-STAYMODAL-012: cancelling changes nothing', () => {
const { onSave, onClose } = open({ minutes: 30 })
fireEvent.change(slider(), { target: { value: '120' } })
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
expect(onSave).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
})
@@ -1,219 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters'
/**
* The lengths a stop usually takes, so the common answer is one tap.
*
* Kept short on purpose: a longer list reads as a form, and anything not on it is what
* the slider is for.
*/
const PRESETS = [15, 30, 45, 60, 90, 120]
/** As far as the slider goes, in minutes. Beyond four hours it is a day, not a stop. */
const MAX = 240
/** The step the slider and the two buttons move in. */
const STEP = 5
/** The stop being given a length. */
export interface StayDraft {
placeId: number
name: string
minutes: number | null
/** When the drive gets here, so the dialog can show what the stay pushes back. */
arrival?: string | null
}
interface RoadtripStayModalProps {
stop: StayDraft | null
onClose: () => void
/** Zero clears it: the rail reads "no stay" and "nothing planned" as the same thing. */
onSave: (placeId: number, minutes: number) => Promise<void> | void
}
/** The slider's own track and thumb, drawn from tokens so both themes get it right. */
const SLIDER = [
'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-surface-tertiary',
'[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-card',
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--bg-card)]',
'[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:border-0',
'[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent',
].join(' ')
/**
* How long the traveller stays at one stop.
*
* A road-trip idea rather than a property of the place: it is what pushes every later
* arrival back, so it belongs beside the drive times rather than in the place form, which
* is why this opens from the rail and nowhere else.
*
* Built around the number instead of around a field. The chosen length is the largest
* thing on screen and updates as the slider moves, and under it the dialog answers the
* question the number is actually being chosen for: given when the drive arrives, when
* does it leave again. Typing minutes into a box could never show that.
*
* The value is `places.duration_minutes`, which the schedule has read for years and
* nothing has ever been able to write. Cleared as a zero rather than a null, because the
* update statement folds a null into "leave it alone" — a null could give a stop a stay
* but never take one away.
*/
export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripStayModalProps): React.ReactElement | null {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [minutes, setMinutes] = useState(0)
const [saving, setSaving] = useState(false)
// Reopened on a different stop, so it starts from that stop's own value rather than
// from whatever the last one was left on.
useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop])
// What the stay does to the day, worked out live: the arrival is fixed by the drive,
// the departure is the only thing this dialog moves.
const times = useMemo(() => {
const arrival = parseClock(stop?.arrival)
if (arrival === null) return null
return {
arrive: formatClockTime(formatClock(arrival), is12h),
leave: formatClockTime(formatClock(arrival + minutes), is12h),
}
}, [stop?.arrival, minutes, is12h])
if (!stop) return null
const commit = async (value: number) => {
if (saving) return
setSaving(true)
try {
await onSave(stop.placeId, value)
onClose()
} finally {
setSaving(false)
}
}
const nudge = (delta: number) => setMinutes(m => Math.min(MAX, Math.max(0, m + delta)))
return (
<Modal isOpen onClose={onClose} size="sm" title={
<span className="flex items-center gap-2">
<Hourglass size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.stop.stay')}
</span>
}>
<div className="flex flex-col gap-5">
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
{/* The number, the way a timer shows one. Minus and plus flank it so a value can
be nudged without hunting for the slider's thumb. */}
<div className="flex items-center justify-center gap-4">
<button
type="button"
onClick={() => nudge(-STEP)}
disabled={minutes === 0}
aria-label={`-${STEP}`}
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
>
<Minus size={15} aria-hidden />
</button>
<span
aria-live="polite"
className="min-w-[8rem] text-center text-title font-semibold tabular-nums tracking-tight text-content"
>
{minutes > 0 ? formatDurationShort(minutes * 60) : t('roadtrip.stay.none')}
</span>
<button
type="button"
onClick={() => nudge(STEP)}
disabled={minutes >= MAX}
aria-label={`+${STEP}`}
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
>
<Plus size={15} aria-hidden />
</button>
</div>
<input
type="range"
min={0}
max={MAX}
step={STEP}
value={minutes}
onChange={e => setMinutes(Number(e.target.value))}
aria-label={t('roadtrip.stay.custom')}
className={SLIDER}
/>
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
decides it — so the arrow shows the one end this dialog does move. */}
{times ? (
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
</span>
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
</span>
</div>
) : null}
<div className="flex flex-wrap justify-center gap-1.5">
{PRESETS.map(value => (
<button
key={value}
type="button"
onClick={() => setMinutes(value)}
className={`rounded-full border px-3 py-1 text-caption font-medium tabular-nums transition-colors ${
minutes === value
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface-card text-content-secondary hover:border-content-faint'
}`}
>
{formatDurationShort(value * 60)}
</button>
))}
</div>
</div>
<div className="mt-6 flex items-center gap-2">
{/* Removing is the same write as setting one, so it sits with the other actions
rather than behind a confirmation. */}
<button
type="button"
disabled={saving || !stop.minutes}
onClick={() => void commit(0)}
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
>
{t('roadtrip.stay.none')}
</button>
<button
type="button"
onClick={onClose}
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={saving}
onClick={() => void commit(minutes)}
className="rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text transition-opacity disabled:opacity-40"
>
{t('common.save')}
</button>
</div>
</Modal>
)
}
@@ -1,277 +0,0 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
import type { CorridorPoi } from './useCorridorPois'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
return {
osm_id: 'node:9',
name: 'Aral Autohof',
lat: 53.1,
lng: 10.2,
category: 'fuel',
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
offRouteKm: 1.2,
alongKm: 120,
...over,
} as CorridorPoi
}
const draft = (over: Partial<RoadtripStopDraft> = {}): RoadtripStopDraft => ({
poi: poi(),
dayId: 4,
position: 2,
dayNumber: 1,
...over,
})
const noop = {
onClose: vi.fn(),
onSave: vi.fn(),
onMoreDetails: vi.fn(),
}
describe('RoadtripStopPopup', () => {
it('FE-ROADTRIP-STOPPOPUP-001: without a draft there is nothing to show', () => {
const { container } = wrap(<RoadtripStopPopup draft={null} {...noop} />)
expect(container).toBeEmptyDOMElement()
})
it('FE-ROADTRIP-STOPPOPUP-002: says where the stop will land, counting from one', () => {
wrap(<RoadtripStopPopup draft={draft({ position: 2, dayNumber: 3 })} {...noop} />)
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
// Position 2 in the array is the third stop for a reader.
expect(screen.getByText('Day 3, as stop 3')).toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-003: the kind the search found is preselected', () => {
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: /Charging/ })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-ROADTRIP-STOPPOPUP-004: saving reports the kind and how long it takes', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
// Fuel suggests ten minutes; nobody plans an hour for it.
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
})
it('FE-ROADTRIP-STOPPOPUP-005: picking another kind brings its own duration along', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
// Charging is half an hour, not the ten minutes a tank takes.
fireEvent.click(screen.getByRole('button', { name: /Charging/ }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 30 }))
})
it('FE-ROADTRIP-STOPPOPUP-006: a duration chosen by hand survives the save', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
})
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
})
/**
* Every category the corridor can search for, with the name its button carries.
*
* The keys are the contract with `CATEGORY_OSM_FILTERS` on the server, which labels
* each hit with the category its own OSM tag matched. A key the popup does not know
* preselects nothing, and the stop is saved as an ordinary place — which is exactly
* how food and sights used to end up as numbered destinations in the middle of a drive.
*/
const CORRIDOR_KINDS: [string, string][] = [
['fuel', 'Fuel'],
['charging', 'Charging'],
['rest_area', 'Rest area'],
['campsite', 'Campsite'],
['restaurant', 'Food'],
['sights', 'Sights'],
]
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
for (const [, other] of CORRIDOR_KINDS) {
if (other === label) continue
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
}
unmount()
})
it('FE-ROADTRIP-STOPPOPUP-012: a kind nobody knows preselects nothing', () => {
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'museum', name: 'Albertinum' }) })} {...noop} />)
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: /Campsite/ })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-ROADTRIP-STOPPOPUP-009: warns when the same place is already on the trip', () => {
const { unmount } = wrap(<RoadtripStopPopup draft={draft()} {...noop} duplicateName="Aral Autohof" />)
expect(screen.getByText(/already on this trip/)).toBeInTheDocument()
unmount()
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
expect(screen.queryByText(/already on this trip/)).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-010: the way out to the full form is offered and reported', () => {
const onMoreDetails = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onMoreDetails={onMoreDetails} />)
fireEvent.click(screen.getByRole('button', { name: 'More details' }))
expect(onMoreDetails).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-STOPPOPUP-011: a save in flight cannot be fired twice', async () => {
let release: () => void = () => {}
const onSave = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
const add = screen.getByRole('button', { name: 'Add' })
fireEvent.click(add)
await waitFor(() => expect(add).toBeDisabled())
fireEvent.click(add)
expect(onSave).toHaveBeenCalledTimes(1)
// Settle it inside act, or React re-enables the button after the test has finished.
await act(async () => { release() })
expect(add).not.toBeDisabled()
})
// ── Overnight mode ─────────────────────────────────────────────────────────
//
// Somewhere to sleep is the one hit that ends the day rather than interrupting the
// drive, so the dialog grows a second mode instead of offering a hotel a dwell of
// thirty minutes.
const overnight = {
days: [
{ id: 4, number: 1, date: '2026-08-31' },
{ id: 5, number: 2, date: '2026-09-01' },
{ id: 6, number: 3, date: '2026-09-02' },
],
defaultEndDayId: 5,
}
const nightDraft = (category: string) => draft({
poi: poi({ category, name: 'Hotel Adlon' }),
overnight,
})
it('FE-ROADTRIP-STOPPOPUP-020: a hotel opens on the overnight mode', () => {
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Overnight' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Check out on')).toBeInTheDocument()
// The two questions a pause asks are gone: a hotel has no kind and no dwell.
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-021: a campsite opens on the pause mode but offers the night', () => {
// It is a service stop today and stays one, but people do sleep at campsites, so the
// choice is offered rather than decided.
wrap(<RoadtripStopPopup draft={nightDraft('campsite')} {...noop} onSaveNight={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Pause' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Overnight' })).toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-022: a petrol station is never offered a night', () => {
// Nothing to choose between, so no switch at all.
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSaveNight={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-023: booking a night reports the check-out day, defaulting to the next', () => {
const onSaveNight = vi.fn()
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={onSaveNight} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '', checkOut: '' })
})
it('FE-ROADTRIP-STOPPOPUP-024: switching back to a pause saves a pause', () => {
const onSave = vi.fn()
const onSaveNight = vi.fn()
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSave={onSave} onSaveNight={onSaveNight} />)
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSaveNight).not.toHaveBeenCalled()
expect(onSave).toHaveBeenCalled()
})
it('FE-ROADTRIP-STOPPOPUP-026: a scheme-less OSM website still leaves the app', () => {
// OSM `website` tags are routinely written without a scheme. As a raw href
// `www.hotel.de` is a RELATIVE url, so the click navigated the planner to
// /trips/<id>/www.hotel.de and the user lost the page they were on.
wrap(
<RoadtripStopPopup
draft={draft({ poi: poi({ category: 'hotel', website: 'www.hotel-adlon.de' }), overnight })}
{...noop}
onSaveNight={vi.fn()}
/>,
)
expect(screen.getByRole('link', { name: 'Website' })).toHaveAttribute('href', 'https://www.hotel-adlon.de')
})
it('FE-ROADTRIP-STOPPOPUP-027: a website that is not a link is not rendered as one', () => {
// Anything that is not http(s) loses the link rather than being handed to
// the browser. The CSP stops a javascript: navigation on a stock deployment,
// but this is defended everywhere else in the app and belongs here too.
for (const bad of ['javascript:alert(1)', 'data:text/html,<script>', 'tel:+49301234']) {
const { unmount } = wrap(
<RoadtripStopPopup
draft={draft({ poi: poi({ category: 'hotel', website: bad }), overnight })}
{...noop}
onSaveNight={vi.fn()}
/>,
)
expect(screen.queryByRole('link', { name: 'Website' }), bad).not.toBeInTheDocument()
unmount()
}
})
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
// leads nowhere.
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} />)
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
})
})
@@ -1,326 +0,0 @@
import React, { useState } from 'react'
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { useTranslation } from '../../i18n/TranslationContext'
import { safeExternalHref } from '../../utils/safeUrl'
import { formatDate } from '../../utils/formatters'
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripStopType } from '@trek/shared'
/**
* The four kinds of stop, with the icon and colour they already carry in the corridor
* search, and how long each one usually takes.
*
* Deliberately not TREK's place categories: those are the traveller's own editable list,
* shared across the whole instance. Refuelling is not a taste, it is a fact about the
* place, so it lives in `places.stop_type` and keeps the palette the search gave it.
*
* The colours are the road-trip palette from `poiCategories.ts` and are the same in both
* themes on purpose — a brand-neutral fuel blue reads as fuel either way.
*/
// The kinds, their icons, their colours and how long each one usually takes all come
// from the one table in stopKinds.ts.
/** How long to stand still, offered as the few answers anyone actually gives. */
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
export interface RoadtripStopDraft {
poi: CorridorPoi
dayId: number
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
position: number
dayNumber: number
/**
* Set only for a hit somebody could sleep at, and it is what turns the popup into two
* modes rather than one. Absent, the dialog asks the two questions it always asked.
*/
overnight?: {
/** The trip's days from this one on, in travel order — the check-out options. */
days: { id: number; number: number; date: string | null }[]
/** The day after this one, or this one when it is the last. */
defaultEndDayId: number
}
}
interface RoadtripStopPopupProps {
draft: RoadtripStopDraft | null
/** Names of stops already on this trip that came from the same OSM object. */
duplicateName?: string | null
onClose: () => void
onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise<void> | void
/**
* Books the hit as a night instead of a pause: the place, its day, and a row in
* day_accommodations, in one go. Only ever called when the draft carries `overnight`.
*/
onSaveNight?: (input: { endDayId: number; checkIn: string; checkOut: string }) => Promise<void> | void
/** Hands over what has been picked here, so the full form opens on the same answer. */
onMoreDetails: (stop: { stopType: RoadtripStopType | null; dwellMinutes: number }) => void
}
/**
* Adding something found along the drive, without the full place form.
*
* The form is right for a place you are planning a day around; it is wrong for a petrol
* station. Everything it asks for — category, price, photo, notes, files — is empty for a
* refuelling stop, and in road trip mode it also drops the place into the unplanned pool,
* which neither column shows. This asks the two questions that matter (what kind of stop,
* how long) and says where it will land, with a way out to the full form for the rest.
*/
export default function RoadtripStopPopup({
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
}: RoadtripStopPopupProps): React.ReactElement | null {
const { t, language } = useTranslation()
// Through the same guard every other external link in the app goes through.
// This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are
// routinely written without a scheme — `www.hotel.de` as a raw href is a
// RELATIVE url, so the click navigated the planner to
// /trips/<id>/www.hotel.de instead of leaving the app. The helper adds the
// scheme for a bare host and drops anything that is not http(s).
const websiteHref = safeExternalHref(draft?.poi.website)
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
// A hotel is a night by default and a campsite a pause, which is what each already
// means everywhere else — but both offer the other, because a campsite is somewhere
// people sleep and a hotel is somewhere people stop for lunch.
const [night, setNight] = useState<boolean>(draft?.poi.category === 'hotel')
const [endDayId, setEndDayId] = useState<number | null>(draft?.overnight?.defaultEndDayId ?? null)
const [checkIn, setCheckIn] = useState('')
const [checkOut, setCheckOut] = useState('')
const [saving, setSaving] = useState(false)
if (!draft) return null
const overnight = draft.overnight
const asNight = !!overnight && !!onSaveNight && night
const kind = STOP_KINDS.find(k => k.key === stopType)
const submit = async (): Promise<void> => {
setSaving(true)
try {
if (asNight) {
await onSaveNight?.({
endDayId: endDayId ?? overnight!.defaultEndDayId,
checkIn,
checkOut,
})
} else {
await onSave({ stopType, dwellMinutes: dwell })
}
} finally {
setSaving(false)
}
}
return (
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
// theme-lint-disable — the road-trip palette, the same one the map pin and the
// result row use; a token here would make the three disagree.
style={{ background: `${kind?.color ?? '#64748B'}1f`, color: kind?.color ?? '#64748B' }}
>
{kind ? <kind.Icon size={17} aria-hidden /> : <ParkingSquare size={17} aria-hidden />}
</span>
<div className="min-w-0 flex-1">
<p className="break-words text-body font-semibold text-content">{draft.poi.name}</p>
<p className="mt-0.5 text-caption text-content-muted">
{t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
</p>
</div>
</div>
{duplicateName ? (
// The full place form warns about duplicates; losing that warning would make
// this the easiest way to add the same petrol station twice.
<p className="flex items-start gap-1.5 rounded-lg bg-warning-soft px-2.5 py-2 text-caption text-warning">
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
{t('roadtrip.stop.duplicate', { name: duplicateName })}
</p>
) : null}
{/* Only where sleeping is on the table. A petrol station gets no choice, because
there is nothing to choose between. */}
{overnight && onSaveNight ? (
<div className="flex rounded-lg border border-edge p-0.5">
{[
{ on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass },
{ on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble },
].map(({ on, label, Icon }) => (
<button
key={label}
type="button"
aria-pressed={night === on}
onClick={() => setNight(on)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-caption transition-colors ${
night === on
? 'bg-accent font-semibold text-accent-text'
: 'text-content-secondary hover:bg-surface-hover'
}`}
>
<Icon size={13} aria-hidden />
{label}
</button>
))}
</div>
) : null}
{asNight ? (
<div className="flex flex-col gap-3">
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stay.checkOutDay')}
</span>
<div className="mt-1.5">
<CustomSelect
value={String(endDayId ?? overnight!.defaultEndDayId)}
onChange={value => setEndDayId(Number(value))}
options={overnight!.days.map(d => ({
value: String(d.id),
// Through the shared helper, like every other day date in
// this feature. Interpolating the column gave this one
// dropdown a bare ISO date while the rail beside it showed
// the same day in the reader's own order.
label: d.date
? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${formatDate(d.date, language)}`
: t('roadtrip.stay.dayNumber', { number: d.number }),
}))}
size="sm"
/>
</div>
</div>
{/* Both optional: leaving them empty is normal, and the server stores null.
A hotel found on a map has no idea when its reception opens. */}
<div className="flex gap-3">
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkIn')}
</span>
<input
type="time"
value={checkIn}
onChange={e => setCheckIn(e.target.value)}
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
/>
</label>
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkOut')}
</span>
<input
type="time"
value={checkOut}
onChange={e => setCheckOut(e.target.value)}
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
/>
</label>
</div>
{/* What OSM knows about the house, which is the whole of what we offer: no
price, no availability, no link into a booking portal. TREK links to maps
and to the place's own site, never to a vendor. */}
{websiteHref || draft.poi.phone ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
{websiteHref ? (
<a
href={websiteHref}
target="_blank"
rel="noreferrer noopener"
className="text-accent hover:underline"
>
{t('places.formWebsite')}
</a>
) : null}
{draft.poi.phone ? (
<a href={`tel:${draft.poi.phone}`} className="text-accent hover:underline">
{draft.poi.phone}
</a>
) : null}
</div>
) : null}
</div>
) : (
<>
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.kind')}
</span>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{STOP_KINDS.map(({ key, labelKey, Icon }) => {
const on = stopType === key
return (
<button
key={key}
type="button"
aria-pressed={on}
onClick={() => {
setStopType(on ? null : key)
// Picking a kind is also picking how long it takes, until the user
// says otherwise — a charge is not a fuel stop.
if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes)
}}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
on
? 'border-transparent bg-accent font-semibold text-accent-text'
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
}`}
>
<Icon size={12} aria-hidden />
{t(labelKey)}
</button>
)
})}
</div>
</div>
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.stay')}
</span>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{DWELL_CHOICES.map(minutes => (
<button
key={minutes}
type="button"
aria-pressed={dwell === minutes}
onClick={() => setDwell(minutes)}
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-caption tabular-nums transition-colors ${
dwell === minutes
? 'border-transparent bg-accent font-semibold text-accent-text'
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
}`}
>
<Hourglass size={11} aria-hidden />
{formatDurationShort(minutes * 60)}
</button>
))}
</div>
</div>
</>
)}
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
<button
type="button"
onClick={() => onMoreDetails({ stopType, dwellMinutes: dwell })}
className="rounded-lg px-2.5 py-1.5 text-caption text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
{t('roadtrip.stop.moreDetails')}
</button>
<button
type="button"
onClick={submit}
disabled={saving}
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{t('roadtrip.poi.add')}
</button>
</div>
</div>
</Modal>
)
}
@@ -1,126 +0,0 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripTrackModal from './RoadtripTrackModal'
import type { FollowTrack, TrackChoice } from './useFollowTrack'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
const track = (over: Partial<TrackChoice> & { id: number; name: string }): TrackChoice => ({
color: '#0ea5e9',
points: [{ lat: 52, lng: 13 }, { lat: 52, lng: 14 }],
lengthKm: 68,
gapKm: 0.4,
...over,
})
function follow(over: Partial<FollowTrack> = {}): FollowTrack {
return {
dayId: 7,
open: vi.fn(),
close: vi.fn(),
tracks: [track({ id: 3, name: 'Atlantic Road' })],
busy: false,
round: 0,
error: null,
outcome: null,
apply: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
viaCount: 0,
available: true,
current: null,
namesByDay: {},
...over,
}
}
describe('RoadtripTrackModal', () => {
it('FE-TRACKMODAL-001: closed is nothing at all, not a hidden dialog', () => {
const { container } = wrap(<RoadtripTrackModal follow={follow({ dayId: null })} dayNumber={1} />)
expect(container.firstChild).toBeNull()
})
it('FE-TRACKMODAL-002: a trip with no tracks says where tracks come from', () => {
wrap(<RoadtripTrackModal follow={follow({ tracks: [] })} dayNumber={2} />)
expect(screen.getByText('No tracks in this trip')).toBeTruthy()
expect(screen.getByText(/GPX or KML/)).toBeTruthy()
})
it('FE-TRACKMODAL-003: a track close to the day says so rather than quoting a distance', () => {
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
expect(screen.getByText(/along this day/)).toBeTruthy()
expect(screen.getByText(/68 km long/)).toBeTruthy()
})
it('FE-TRACKMODAL-004: a track that runs elsewhere is offered with the distance to here', () => {
wrap(<RoadtripTrackModal follow={follow({ tracks: [track({ id: 9, name: 'Elsewhere', gapKm: 240 })] })} dayNumber={1} />)
expect(screen.getByText(/240 km off this day/)).toBeTruthy()
})
it('FE-TRACKMODAL-005: nothing is applied until a track is picked', () => {
const state = follow()
wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
const action = screen.getByRole('button', { name: 'Follow this track' })
expect((action as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByText('Atlantic Road'))
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(false)
// What it will do, before it does it: a track becomes a dozen waypoints on the day.
expect(screen.getByText(/via points/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Follow this track' }))
expect(state.apply).toHaveBeenCalledWith(3)
})
it('FE-TRACKMODAL-006: a picked track can be unpicked', () => {
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
fireEvent.click(screen.getByText('Atlantic Road'))
fireEvent.click(screen.getByText('Atlantic Road'))
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(true)
})
it('FE-TRACKMODAL-007: a run says which round it is on rather than going blank', () => {
wrap(<RoadtripTrackModal follow={follow({ busy: true, round: 4 })} dayNumber={1} />)
expect(screen.getByRole('status').textContent).toMatch(/round 4/)
})
it('FE-TRACKMODAL-008: a finished run reports how close the drive got', () => {
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 6, strayKm: 0.8, capped: false } })} dayNumber={1} />)
expect(screen.getByRole('status').textContent).toMatch(/6 via points placed/)
})
it('FE-TRACKMODAL-009: a run that ran out of vias admits the gap it left', () => {
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 12, strayKm: 2.4, capped: true } })} dayNumber={1} />)
// The honest form: it did what it could, and the number says how much that was worth.
expect(screen.getByRole('status').textContent).toMatch(/no closer than/)
})
it('FE-TRACKMODAL-010: a drive that already followed the track is not dressed up as work', () => {
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 0, strayKm: 0, capped: false } })} dayNumber={1} />)
expect(screen.getByRole('status').textContent).toMatch(/already followed/)
})
it('FE-TRACKMODAL-011: a failure is an alert, not a silent nothing', () => {
wrap(<RoadtripTrackModal follow={follow({ error: 'route' })} dayNumber={1} />)
expect(screen.getByRole('alert').textContent).toMatch(/routing service/)
})
it('FE-TRACKMODAL-012: dropping the day\'s vias is offered only once it has some', () => {
const state = follow({ viaCount: 5 })
const { rerender } = wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
const drop = screen.getByRole('button', { name: /Drop 5 via points/ })
fireEvent.click(drop)
expect(state.clear).toHaveBeenCalled()
rerender(<TranslationProvider><RoadtripTrackModal follow={follow({ viaCount: 0 })} dayNumber={1} /></TranslationProvider>)
expect(screen.queryByRole('button', { name: /Drop/ })).toBeNull()
})
it('FE-TRACKMODAL-013: while it runs, nothing else in the dialog can be touched', () => {
wrap(<RoadtripTrackModal follow={follow({ busy: true, viaCount: 2 })} dayNumber={1} />)
expect((screen.getByRole('button', { name: /Atlantic Road/ }) as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByRole('button', { name: /Drop 2 via points/ }) as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -1,193 +0,0 @@
import React, { useEffect, useState } from 'react'
import { AlertTriangle, Check, RotateCw, Spline, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import EmptyState from '../shared/EmptyState'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { FS } from './typeScale'
import type { FollowTrack, TrackChoice } from './useFollowTrack'
/**
* Picking the track a day drives along.
*
* The list is the whole dialog. Everything a traveller needs to tell two imported tracks
* apart is on the row — the colour the map draws it in, how long it runs, and how far it
* is from this particular day — and the last of those is why the list is ordered the way
* it is: a trip carrying every track of a three-week tour still opens on the one beside
* today's drive.
*
* Applying is slow and says so. It is a routing round trip per refinement, half a minute
* on a long scenic route, and a dialog that went blank for that long would read as broken.
*/
/** A track close enough that it is plainly about this day rather than another one. */
const ON_THIS_DAY_KM = 2
function TrackRow({ track, disabled, onPick }: {
track: TrackChoice
disabled: boolean
onPick: () => void
}): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const onDay = track.gapKm <= ON_THIS_DAY_KM
return (
<li>
<button
type="button"
disabled={disabled}
onClick={onPick}
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-start transition-colors hover:bg-surface-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{/* The line's own colour, as a short stroke rather than a dot: it is a line on the
map, and a dot beside a name reads as a place. */}
<span
className="h-1 w-6 shrink-0 rounded-full"
// theme-lint-disable — the colour the track carries, the same one the map draws.
style={{ background: track.color || 'var(--text-faint)' }}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={{ fontSize: FS.name }}>
{track.name || t('roadtrip.track.untitled')}
</span>
<span className="block truncate tabular-nums text-content-faint" style={{ fontSize: FS.meta }}>
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, distanceUnit) })}
{' · '}
{onDay
? t('roadtrip.track.onDay')
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, distanceUnit) })}
</span>
</span>
</button>
</li>
)
}
export default function RoadtripTrackModal({ follow, dayNumber }: {
follow: FollowTrack
/** The day's number, so the dialog says which drive it is about to reshape. */
dayNumber: number
}): React.ReactElement | null {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [picked, setPicked] = useState<number | null>(null)
const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow
// A track picked on one day means nothing on the next.
useEffect(() => { setPicked(null) }, [dayId])
if (dayId === null) return null
const chosen = tracks.find(track => track.id === picked) ?? null
return (
<Modal
isOpen
onClose={follow.close}
size="sm"
title={
<span className="flex items-center gap-2">
<Spline size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.track.title', { number: dayNumber })}
</span>
}
>
<div className="flex flex-col gap-3">
{/* What the day follows today, before anything is picked. Read back from the day
rather than remembered from the run that applied it, which is the whole reason
it is stored: it has to survive a reload. */}
{current ? (
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
{t('roadtrip.track.current', { name: current.name })}
</p>
) : null}
{tracks.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-2">
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
<p className="text-center text-caption text-content-faint">{t('roadtrip.track.emptyHint')}</p>
</div>
) : (
<ul className="-mx-2 max-h-[280px] overflow-y-auto">
{tracks.map(track => (
<TrackRow
key={track.id}
track={track}
disabled={busy}
onPick={() => setPicked(track.id === picked ? null : track.id)}
/>
))}
</ul>
)}
{/* What the pick will do, before it is done. The count is the honest part: a track
becomes a dozen waypoints on the day, and a traveller who later wonders where
they came from should have been told here. */}
{chosen ? (
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
{t('roadtrip.track.explain')}
</p>
) : null}
{busy ? (
<p className="flex items-center gap-2 text-caption text-content-secondary" role="status">
<RotateCw size={13} className="animate-spin shrink-0" aria-hidden />
{t('roadtrip.track.working', { round })}
</p>
) : null}
{error ? (
<p className="flex items-start gap-1.5 text-caption text-danger" role="alert">
<AlertTriangle size={13} className="mt-px shrink-0" aria-hidden />
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
</p>
) : null}
{outcome && !busy && !error ? (
<p className="flex items-start gap-1.5 text-caption text-content-secondary" role="status">
<Check size={13} className="mt-px shrink-0 text-success" aria-hidden />
{outcome.vias === 0
? t('roadtrip.track.already')
: outcome.capped
? t('roadtrip.track.capped', {
count: outcome.vias,
distance: formatDistance(outcome.strayKm, distanceUnit),
})
: t('roadtrip.track.done', {
count: outcome.vias,
distance: formatDistance(outcome.strayKm, distanceUnit),
})}
</p>
) : null}
<div className="flex items-center gap-2">
{/* Offered whenever the day carries vias, whether this dialog put them there or
a dragged handle did: "put it back the way the router had it" is the same
wish either way. */}
{viaCount > 0 ? (
<button
type="button"
disabled={busy}
onClick={() => { void follow.clear() }}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<Trash2 size={13} aria-hidden />
{t('roadtrip.track.clear', { count: viaCount })}
</button>
) : null}
<button
type="button"
disabled={!chosen || busy}
onClick={() => { if (chosen) void follow.apply(chosen.id) }}
className="ms-auto rounded-lg bg-accent px-3 py-2 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-40"
>
{t('roadtrip.track.action')}
</button>
</div>
</div>
</Modal>
)
}
@@ -1,117 +0,0 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import StopKindPicker from './StopKindPicker'
import { STOP_KINDS } from './stopKinds'
/**
* FE-STOPKIND-001..009 — turning a place on the drive into a pause, and back.
*
* Opened from the stop's own number, because the number is what changes: a
* service stop has none. The popover is a portal hung under that element, so
* what is worth pinning is the part a portal makes easy to get wrong — that it
* renders nothing without an anchor, that clicking outside and Escape both close
* it, and that picking the kind a stop already has takes it back off rather than
* setting it twice.
*/
let anchor: HTMLElement
beforeEach(() => {
anchor = document.createElement('button')
document.body.appendChild(anchor)
})
afterEach(() => { anchor.remove() })
function open(over: { current?: string | null; onPick?: (k: string | null) => void; onClose?: () => void } = {}) {
const onPick = over.onPick ?? vi.fn()
const onClose = over.onClose ?? vi.fn()
render(
<StopKindPicker
anchor={anchor}
current={over.current ?? null}
onPick={onPick as never}
onClose={onClose}
/>,
)
return { onPick, onClose }
}
describe('StopKindPicker', () => {
it('FE-STOPKIND-001: without an anchor there is nothing to hang under', () => {
const onPick = vi.fn()
render(<StopKindPicker anchor={null} current={null} onPick={onPick} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-STOPKIND-002: every kind the rail draws is offered, as a disc rather than a word', () => {
open()
const dialog = screen.getByRole('dialog')
// One button per kind, plus none extra: six rows of text for six icons would
// be a menu where a palette does.
const buttons = dialog.querySelectorAll('button[aria-pressed]')
expect(buttons).toHaveLength(STOP_KINDS.length)
})
it('FE-STOPKIND-003: picking a kind reports it', () => {
const { onPick } = open()
const first = STOP_KINDS[0]
fireEvent.click(screen.getAllByRole('button', { pressed: false })[0])
expect(onPick).toHaveBeenCalledWith(first.key)
})
it('FE-STOPKIND-004: picking the kind it already is takes it back off', () => {
// The control is its own toggle. Without this, changing your mind about a
// fuel stop means finding the separate undo below.
const kind = STOP_KINDS[0].key
const { onPick } = open({ current: kind })
fireEvent.click(screen.getByRole('button', { pressed: true }))
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPKIND-005: the current kind is the pressed one, and it is the only one', () => {
open({ current: STOP_KINDS[1].key })
expect(screen.getAllByRole('button', { pressed: true })).toHaveLength(1)
})
it('FE-STOPKIND-006: the way back to a destination appears only once there is one', () => {
const { onPick } = open({ current: STOP_KINDS[0].key })
const back = screen.getByRole('button', { name: /destination/i })
fireEvent.click(back)
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPKIND-007: on an ordinary place it would say "leave everything as it is", so it is absent', () => {
open({ current: null })
expect(screen.queryByRole('button', { name: /destination/i })).not.toBeInTheDocument()
})
it('FE-STOPKIND-008: a click outside closes it, a click inside does not', () => {
const { onClose } = open()
fireEvent.mouseDown(screen.getByRole('dialog'))
expect(onClose).not.toHaveBeenCalled()
// The anchor is not "outside" either: clicking the number again is what
// toggles the picker, and closing here would fight that.
fireEvent.mouseDown(anchor)
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(document.body)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-STOPKIND-009: Escape closes it, and so does scrolling the rail underneath', () => {
const { onClose } = open()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
// Following the anchor would mean measuring on every frame for a menu that
// is open for two seconds; closing is the cheaper honest answer.
fireEvent.scroll(window)
expect(onClose).toHaveBeenCalledTimes(2)
})
})
@@ -1,133 +0,0 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MapPin } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { STOP_KINDS } from './stopKinds'
import type { RoadtripStopType } from '@trek/shared'
/**
* Turning a place on the drive into a pause, and back.
*
* Opened from the stop's own number, because the number is exactly what changes: a
* service stop has none. That makes the control its own preview — click the 3, pick the
* pump, and the 3 becomes an orange disc while everything below renumbers itself.
*
* The kinds are the discs the rail already draws rather than a list of words. At this
* size the colour and the shape are the label: a traveller who has seen one petrol stop
* on the map knows the orange pump before reading anything, and six rows of text for six
* icons would be a menu where a palette does.
*
* Only for stops on a drive. A place with no coordinates never reaches the rail, and a
* hotel is not offered here at all, because sleeping somewhere is not a pause in the
* driving — that path goes through the accommodation it books.
*/
const DISC = 'grid place-items-center rounded-full transition-transform'
export default function StopKindPicker({ anchor, current, onPick, onClose }: {
/** The element the popover hangs under, usually the stop's number. */
anchor: HTMLElement | null
current: string | null
onPick: (kind: RoadtripStopType | null) => void
onClose: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Measured after paint, before the browser shows it: reading the size first and then
// placing it is what stops the panel appearing at 0,0 for one frame.
useLayoutEffect(() => {
if (!anchor || !ref.current) return
const a = anchor.getBoundingClientRect()
const p = ref.current.getBoundingClientRect()
const gap = 8
let top = a.bottom + gap
// Flips above when there is no room below, which is most of the rail on a short
// window: a panel clipped by the viewport is one that cannot be used at all.
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
// Centred on the column, not on the number. The number sits hard against the left
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
// the map. Vertically it still follows the row it belongs to.
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
const left = Math.min(
Math.max(8, column.left + column.width / 2 - p.width / 2),
window.innerWidth - p.width - 8,
)
setPos({ top, left })
}, [anchor])
useEffect(() => {
if (!anchor) return
const onDown = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (anchor.contains(e.target as Node)) return
onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
// Any scroll moves the anchor out from under the panel, and following it would mean
// measuring on every frame for a menu that is open for two seconds.
window.addEventListener('scroll', onClose, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onClose, true)
}
}, [anchor, onClose])
if (!anchor) return null
return createPortal(
<div
ref={ref}
role="dialog"
aria-label={t('roadtrip.stop.kind')}
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
>
<div className="flex gap-1">
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
const on = current === key
return (
<Tooltip key={key} label={t(labelKey)}>
<button
type="button"
aria-pressed={on}
aria-label={t(labelKey)}
onClick={() => onPick(on ? null : key)}
className="group grid h-11 w-11 place-items-center rounded-xl transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span
className={`${DISC} h-7 w-7 group-hover:scale-110 ${on ? 'ring-2 ring-offset-2 ring-offset-surface-card' : ''}`}
// theme-lint-disable — the road-signage palette from `roadtripModel`, the
// same one the rail disc and the map pin use, so the choice looks like
// what it will become.
style={{ background: color, color: '#fff', ...(on ? { boxShadow: `0 0 0 2px ${color}` } : {}) }} // theme-lint-disable — road-signage palette
>
<Icon size={14} strokeWidth={2.2} aria-hidden />
</span>
</button>
</Tooltip>
)
})}
</div>
{/* Only once there is something to undo. On an ordinary place it would be a button
that says "leave everything as it is". */}
{current ? (
<button
type="button"
onClick={() => onPick(null)}
className="mt-1 flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<MapPin size={14} className="shrink-0 text-content-faint" aria-hidden />
{t('roadtrip.stop.backToDestination')}
</button>
) : null}
</div>,
document.body,
)
}
@@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest'
import { ACCESS_SPUR_MIN_M, ACCESS_LABEL_MIN_M, spurFor, spurWorthLabelling } from './accessSpur'
import type { SnappedWaypoint } from '../../types'
const snap = (meters: number): SnappedWaypoint => ({
asked: [53.5, 10.0],
at: [53.51, 10.01],
meters,
})
describe('access spur', () => {
it('FE-ACCESSSPUR-001: a gap worth showing becomes the line from the place to the road', () => {
const s = snap(400)
expect(spurFor(s)).toEqual([s.asked, s.at])
})
it('FE-ACCESSSPUR-002: map precision is not a gap', () => {
// A place pinned off an aerial photo lands a few metres from the centreline it
// belongs to all the time. Drawing those would put a dash on nearly every stop.
expect(spurFor(snap(0))).toBeNull()
expect(spurFor(snap(ACCESS_SPUR_MIN_M - 1))).toBeNull()
})
it('FE-ACCESSSPUR-003: the threshold itself counts as a gap', () => {
expect(spurFor(snap(ACCESS_SPUR_MIN_M))).not.toBeNull()
})
it('FE-ACCESSSPUR-004: nothing to draw when the router said nothing', () => {
expect(spurFor(undefined)).toBeNull()
expect(spurFor(null)).toBeNull()
})
it('FE-ACCESSSPUR-005: a NaN distance is not a gap either', () => {
// Recomputed distances can come out NaN from a malformed coordinate, and NaN >= n is
// false — asserted rather than assumed, because the guard reads as a plain comparison.
expect(spurFor(snap(Number.NaN))).toBeNull()
})
it('FE-ACCESSSPUR-006: only a walk long enough to change the plan gets a number', () => {
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M)).toBe(true)
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M - 1)).toBe(false)
expect(spurWorthLabelling(null)).toBe(false)
expect(spurWorthLabelling(undefined)).toBe(false)
})
it('FE-ACCESSSPUR-007: the line is drawn but left unlabelled in between', () => {
// The band where the map says "there is a gap" and the rail stays quiet.
const between = (ACCESS_SPUR_MIN_M + ACCESS_LABEL_MIN_M) / 2
expect(spurFor(snap(between))).not.toBeNull()
expect(spurWorthLabelling(between)).toBe(false)
})
})
@@ -1,47 +0,0 @@
import type { SnappedWaypoint } from '../../types'
/**
* The gap between a place and the road the drive actually reaches it from.
*
* A router snaps every waypoint to the nearest road before it starts, and OSRM does it
* with no distance limit. A place set back from the road is therefore driven to from
* somewhere else, and the line drawn for that drive begins at the somewhere else without
* saying so — which is what a viewpoint above a valley, a farmhouse down a track or a
* marina at the end of a pier all look like today: a route that stops short for no
* visible reason, or worse, one that appears to cut across open ground.
*
* Drawn as a dashed spur rather than hidden or silently absorbed, which is the same
* answer Google gives: the drive ends at the road, the rest is not driving, and the
* picture should say which is which.
*/
/**
* Below this, the gap is map precision rather than missing road.
*
* Thirty metres is roughly the width of a dual carriageway plus its verge, and a place
* pinned from an aerial photo lands that far from the centreline it belongs to all the
* time. Drawing those would put a dash on almost every stop and teach people to ignore
* the mark entirely.
*/
export const ACCESS_SPUR_MIN_M = 30
/**
* Above this, the spur is worth a number as well as a line.
*
* A quarter of a kilometre is where "the car park is over there" turns into something
* that changes the plan: a walk with luggage, a locked gate, a track a hire car should
* not be on. Below it the dash alone says enough.
*/
export const ACCESS_LABEL_MIN_M = 250
/** The two-point line from a place to the road it was routed from, or null if there is none worth drawing. */
export function spurFor(snapped: SnappedWaypoint | undefined | null): [[number, number], [number, number]] | null {
if (!snapped) return null
if (!(snapped.meters >= ACCESS_SPUR_MIN_M)) return null
return [snapped.asked, snapped.at]
}
/** Whether this gap is far enough that the rail should print the distance beside it. */
export function spurWorthLabelling(meters: number | null | undefined): boolean {
return typeof meters === 'number' && meters >= ACCESS_LABEL_MIN_M
}
@@ -1,28 +0,0 @@
/**
* How the offered routes are drawn, after Apple Maps.
*
* All of them blue, not one blue and the rest grey: they are all the same road network
* and all equally drivable, so the difference between them is emphasis, not category. The
* chosen one is the full-strength blue the route already uses; the others are a pale blue
* that stays visible on a grey basemap — Positron is almost entirely greys, and a grey
* line on it disappears, which is exactly what happened when these were grey.
*
* Fixed values rather than theme tokens: they have to match the blue the route itself is
* drawn in, which is fixed too, and a token that shifted with the colour scheme would
* break the pairing.
*/
/** The route as chosen — same blue as the drawn trip route. */
export const ALT_PRIMARY = '#0a84ff'
/** The other offers: unmistakably blue, unmistakably secondary. */
export const ALT_SECONDARY = '#7eb8f0'
/** Under both, so either reads on a pale map, a dark one or a satellite tile. */
export const ALT_CASING = '#ffffff'
/**
* The label colours, again after Apple: the chosen route's time is a filled blue pill,
* the alternatives' are near-black. Both carry white text.
*/
export const ALT_LABEL_PRIMARY_BG = '#0a84ff'
export const ALT_LABEL_SECONDARY_BG = '#2b2f36'
export const ALT_LABEL_TEXT = '#ffffff'
@@ -1,146 +0,0 @@
import { describe, it, expect } from 'vitest'
import { buildAlternativeOverlays } from './alternativeOverlays'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator'
/**
* FE-ALTOVL-001..010 — 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
* stretch two routes share appears pinned to the wrong road, which is the whole
* reason this is not simply the midpoint.
*/
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
function line(n: number, lat: number): [number, number][] {
return Array.from({ length: n }, (_, i) => [lat, i / 10] as [number, number])
}
function alt(over: Partial<RouteAlternative & { current?: boolean }> = {}): RouteAlternative & { current?: boolean } {
return {
coordinates: line(20, 0),
duration: 3600,
distance: 100_000,
...over,
} as RouteAlternative & { current?: boolean }
}
describe('buildAlternativeOverlays', () => {
it('FE-ALTOVL-001: nothing to choose between is not a choice', () => {
// One answer drawn as an "alternative" would just double the route already
// on the map.
expect(buildAlternativeOverlays(undefined, LABELS)).toEqual([])
expect(buildAlternativeOverlays([], LABELS)).toEqual([])
expect(buildAlternativeOverlays([alt()], LABELS)).toEqual([])
})
it('FE-ALTOVL-002: the quickest is the blue one when no road is being driven yet', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
LABELS,
)
expect(out[1].color).toBe(ALT_PRIMARY)
expect(out[1].labelBg).toBe(ALT_LABEL_PRIMARY_BG)
expect(out[0].color).toBe(ALT_SECONDARY)
expect(out[0].labelBg).toBe(ALT_LABEL_SECONDARY_BG)
})
it('FE-ALTOVL-003: the road already being driven takes the blue, even when it is slower', () => {
// Blue means "the road you are on". Handing it to the quickest offer instead
// would recolour the drive the traveller deliberately bent.
const out = buildAlternativeOverlays(
[alt({ duration: 7200, current: true, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
LABELS,
)
expect(out[0].color).toBe(ALT_PRIMARY)
expect(out[0].note).toBe('Current')
expect(out[1].color).toBe(ALT_SECONDARY)
})
it('FE-ALTOVL-004: what a road IS beats what it is not', () => {
// A way offered because the motorway was left out says so, rather than being
// labelled by how much slower it is — being off the motorway is the reason
// somebody would take it.
const out = buildAlternativeOverlays(
[
alt({ duration: 3600, coordinates: line(20, 0) }),
alt({ duration: 5400, avoids: 'motorway', coordinates: line(20, 1) }),
alt({ duration: 5400, avoids: 'toll', coordinates: line(20, 2) }),
],
LABELS,
)
expect(out.map(o => o.note)).toEqual(['Fastest', 'No motorway', 'No tolls'])
})
it('FE-ALTOVL-005: a plain slower offer carries no note at all', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 3600, coordinates: line(20, 0) }), alt({ duration: 5400, coordinates: line(20, 1) })],
LABELS,
)
expect(out[1].note).toBe('')
})
it('FE-ALTOVL-006: how much slower is measured against the quickest, and never negative', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
LABELS,
)
expect(out[0].slowerThanQuickest).toBe(1800)
expect(out[1].slowerThanQuickest).toBe(0)
expect(out.every(o => o.slowerThanQuickest >= 0)).toBe(true)
})
it('FE-ALTOVL-007: a label hangs where its own road is unlike every other', () => {
// Two roads that share both ends and part in the middle. The midpoint of the
// shared stretch would put both labels on the same line; the point furthest
// from the other route puts each on the stretch only it uses.
const shared = (lat: number): [number, number][] => [
[0, 0], [0, 1], [lat, 2], [lat, 3], [0, 4], [0, 5],
]
const out = buildAlternativeOverlays(
[alt({ coordinates: shared(0.5) }), alt({ coordinates: shared(-0.5) })],
LABELS,
)
// Each label sits on its own detour, not on the common ends.
expect(Math.abs(out[0].at.lat)).toBeCloseTo(0.5, 5)
expect(Math.abs(out[1].at.lat)).toBeCloseTo(0.5, 5)
expect(out[0].at.lat).not.toBe(out[1].at.lat)
})
it('FE-ALTOVL-008: a route with no coordinates gets a label anchor rather than crashing', () => {
const out = buildAlternativeOverlays(
[alt({ coordinates: [] }), alt({ coordinates: line(20, 1) })],
LABELS,
)
expect(out).toHaveLength(2)
expect(out[0].at).toEqual({ lat: 0, lng: 0 })
})
it('FE-ALTOVL-009: the drive time is carried through as text and as seconds', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 3600, distance: 42_000 }), alt({ duration: 5400, coordinates: line(20, 1) })],
LABELS,
)
expect(out[0].duration).toBe(3600)
expect(out[0].distance).toBe(42_000)
// The label is the readable form of the same number.
expect(out[0].label).toBeTruthy()
expect(out[0].index).toBe(0)
expect(out[1].index).toBe(1)
})
it('FE-ALTOVL-010: only one road is ever the blue one', () => {
const out = buildAlternativeOverlays(
[
alt({ duration: 3600, coordinates: line(20, 0) }),
alt({ duration: 3600, coordinates: line(20, 1) }),
alt({ duration: 3600, coordinates: line(20, 2) }),
],
LABELS,
)
expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1)
})
})
@@ -1,125 +0,0 @@
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'
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
export interface AlternativeOverlay {
index: number
coordinates: [number, number][]
color: string
/** The drive time, the way Apple Maps puts it on the road itself. */
label: string
/** The second line under it — "Fastest", "Current" — or empty for the plain ones. */
note: string
/** How long this way takes and how far it is, for the list beside the map. */
duration: number
distance: number
/** Seconds more than the quickest offer; negative never happens by construction. */
slowerThanQuickest: number
labelBg: string
/** Where to hang the label — a point on this route and on no other. */
at: { lat: number; lng: number }
}
/**
* Squared distance between two points, in degrees, with longitude scaled by latitude.
*
* Only ever compared against other values from this same function, so degrees are fine
* and a haversine would cost more for an answer nobody reads.
*/
function roughDist(a: [number, number], b: [number, number]): number {
const dLat = a[0] - b[0]
const dLng = (a[1] - b[1]) * Math.cos((a[0] * Math.PI) / 180)
return dLat * dLat + dLng * dLng
}
/**
* The point of `line` that lies furthest from every one of `others`.
*
* This is where a route is unmistakably itself. Anchoring a label at the geometric
* midpoint instead put it wherever that happened to fall — and on a pair of routes that
* split early and rejoin, the midpoint sits on the shared stretch, so the label appeared
* to be pinned to the wrong road.
*
* Sampled rather than exhaustive: alternatives differ over kilometres, and comparing two
* thousand-point lines in full costs milliseconds for a pixel of accuracy.
*/
function mostDistinctPoint(
line: [number, number][],
others: [number, number][][],
): { lat: number; lng: number } | null {
if (!line.length) return null
if (!others.length) {
const mid = line[Math.floor(line.length / 2)]
return mid ? { lat: mid[0], lng: mid[1] } : null
}
const sample = (l: [number, number][]) => Math.max(1, Math.floor(l.length / 150))
let best: [number, number] | null = null
let bestScore = -1
for (let i = 0; i < line.length; i += sample(line)) {
// How far this point is from the NEAREST other route: a point close to any of them
// is not distinctive, however far it sits from the rest.
let nearestOfAll = Infinity
for (const other of others) {
let nearest = Infinity
const step = sample(other)
for (let j = 0; j < other.length; j += step) {
const d = roughDist(line[i], other[j])
if (d < nearest) nearest = d
}
if (nearest < nearestOfAll) nearestOfAll = nearest
}
if (nearestOfAll > bestScore) { bestScore = nearestOfAll; best = line[i] }
}
return best ? { lat: best[0], lng: best[1] } : null
}
/**
* The routes as the map should draw them, built once so every renderer agrees.
*
* 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.
*/
export function buildAlternativeOverlays(
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
): 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.
const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0)
const anyCurrent = routes.some(r => r.current)
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)
return {
index,
coordinates: route.coordinates,
color: primary ? ALT_PRIMARY : ALT_SECONDARY,
label: formatDurationShort(route.duration),
// What this way IS beats what it is not: a road offered because the motorway was
// left out of it says so, rather than being labelled by how much slower it is —
// that is the reason somebody would take it.
note: route.current
? labels.current
: route.avoids === 'motorway'
? labels.noMotorway
: route.avoids === 'toll'
? labels.noToll
: index === quickest ? labels.fastest : '',
duration: route.duration,
distance: route.distance,
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
at: at ?? { lat: 0, lng: 0 },
}
})
}
@@ -1,157 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
corridorTiles,
distanceToSegmentKm,
haversineKm,
projectOntoRoute,
simplifyLine,
type LatLng,
} from './corridor'
const BERLIN: LatLng = { lat: 52.52, lng: 13.405 }
const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 }
const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 }
describe('haversineKm', () => {
it('measures a known distance', () => {
// Berlin → Dresden is about 165 km as the crow flies.
expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160)
expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170)
})
it('is zero for the same point', () => {
expect(haversineKm(BERLIN, BERLIN)).toBe(0)
})
})
describe('distanceToSegmentKm', () => {
const a: LatLng = { lat: 52, lng: 13 }
const b: LatLng = { lat: 52, lng: 14 }
it('measures perpendicular distance to the line', () => {
const p: LatLng = { lat: 52.09, lng: 13.5 }
// 0.09° of latitude is about 10 km, whatever the longitude does.
expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9)
expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11)
})
it('measures to the end, not to the infinite line, for a point beyond it', () => {
const beyond: LatLng = { lat: 52, lng: 16 }
const d = distanceToSegmentKm(beyond, a, b)
// Two degrees of longitude past the end at 52° north — roughly 137 km.
expect(d).toBeGreaterThan(120)
expect(d).toBeLessThan(150)
})
it('falls back to point distance for a zero-length segment', () => {
expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5)
})
})
describe('projectOntoRoute', () => {
const line = [BERLIN, DRESDEN, PRAGUE]
it('reports how far off the route a point is', () => {
const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line)
expect(onRoute!.offRouteKm).toBeLessThan(15)
const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line)
expect(wayOff!.offRouteKm).toBeGreaterThan(150)
})
it('orders points by how far into the drive they come', () => {
const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)!
const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)!
expect(early.alongKm).toBeLessThan(late.alongKm)
})
it('tells two points on the same long straight apart', () => {
// The petrol stations along one motorway segment must not all report the same
// position in the drive, which is what taking the segment midpoint produced.
const straight = [{ lat: 52, lng: 13 }, { lat: 52, lng: 15 }]
const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)!
const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)!
expect(late.alongKm - early.alongKm).toBeGreaterThan(80)
})
it('returns nothing for a line that is not one', () => {
expect(projectOntoRoute(BERLIN, [])).toBeNull()
expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull()
})
})
describe('corridorTiles', () => {
it('covers a short route with a single box', () => {
const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5)
expect(tiles).toHaveLength(1)
expect(tiles[0].south).toBeLessThan(52.4)
expect(tiles[0].north).toBeGreaterThan(52.52)
})
it('splits a long route into boxes the server will not clamp', () => {
const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10)
expect(tiles.length).toBeGreaterThan(1)
for (const t of tiles) {
expect(t.north - t.south).toBeLessThanOrEqual(0.45)
expect(t.east - t.west).toBeLessThanOrEqual(0.45)
}
})
it('pads longitude more the further north it goes', () => {
const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0]
const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0]
expect(north.east - north.west).toBeGreaterThan(south.east - south.west)
})
it('covers the middle of a long straight leg, not only its two ends', () => {
// A motorway with no bend in it reduces to two points, 250 km apart. Tiling walks
// point to point, so without splitting the step everything between them goes
// unsearched while the search still reports itself complete.
const straight = [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }]
const tiles = corridorTiles(straight, 10)
expect(tiles.length).toBeGreaterThan(4)
// A box somewhere over the middle of the drive, which is what was missing.
const midLat = 53.03
const midLng = 11.7
expect(tiles.some(t => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe(true)
})
it('leaves no gap between consecutive boxes along a route', () => {
const tiles = corridorTiles([{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], 10)
for (let i = 1; i < tiles.length; i++) {
const a = tiles[i - 1]
const b = tiles[i]
// Overlapping or touching in both axes: a hole here is a stretch nobody looks at.
expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0)
expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0)
}
})
it('has nothing to cover for an empty route', () => {
expect(corridorTiles([], 10)).toEqual([])
})
})
describe('simplifyLine', () => {
it('keeps the ends and drops what sits on the line between them', () => {
const straight = [
{ lat: 52, lng: 13 },
{ lat: 52, lng: 13.5 },
{ lat: 52, lng: 14 },
]
expect(simplifyLine(straight, 1)).toEqual([straight[0], straight[2]])
})
it('keeps a point that actually bends the route', () => {
const bent = [
{ lat: 52, lng: 13 },
{ lat: 52.5, lng: 13.5 },
{ lat: 52, lng: 14 },
]
expect(simplifyLine(bent, 1)).toHaveLength(3)
})
it('leaves a two-point line alone', () => {
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
})
})
-234
View File
@@ -1,234 +0,0 @@
/**
* Corridor geometry — "what lies along this drive", without asking Overpass to think
* about a polyline.
*
* The naive form (`around:` with a hundred coordinates) is what every POI provider warns
* about and times out on. OsmAnd solves it the other way round and this follows it: ask
* for plain bounding boxes that cover the route, then measure each candidate's
* perpendicular distance to the line and throw away what is too far. The boxes are what
* the existing /api/maps/pois route already accepts, so no new server surface is needed.
*
* Everything here is pure: no React, no network, no store.
*/
export interface LatLng {
lat: number
lng: number
}
export interface Bbox {
south: number
west: number
north: number
east: number
}
const EARTH_RADIUS_KM = 6371
const KM_PER_DEG_LAT = 111.32
const toRad = (deg: number): number => (deg * Math.PI) / 180
/** Great-circle distance in kilometres. */
export function haversineKm(a: LatLng, b: LatLng): number {
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const h =
Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
}
/**
* Perpendicular distance from a point to a line segment, in kilometres.
*
* Works in a local equirectangular projection around the segment: longitudes shrink by
* cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north.
* Over the few kilometres a corridor spans, the error against a proper geodesic is far
* below the width the user picked.
*/
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
return projectOnSegment(p, a, b).distanceKm
}
/**
* Perpendicular distance to a segment plus where along it the foot of that perpendicular
* falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in
* the drive — taking the segment's midpoint instead makes every petrol station on a long
* straight report the same position.
*/
/** A segment shorter than a millimetre, squared — below this there is nothing to project onto. */
const DEGENERATE_SEGMENT_KM2 = 1e-12
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
const latScale = KM_PER_DEG_LAT
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2))
const ax = a.lng * lngScale
const ay = a.lat * latScale
const bx = b.lng * lngScale
const by = b.lat * latScale
const px = p.lng * lngScale
const py = p.lat * latScale
const dx = bx - ax
const dy = by - ay
const lenSq = dx * dx + dy * dy
// A segment whose two ends are the same point to within a millimetre has no
// direction to project onto, so measure straight to it. Written as a range because
// the ends came through a chain of floating point arithmetic and never land on 0.
if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }
// Clamped so a point beside the segment's end measures to the end, not to the
// infinite line — otherwise a stop 200 km past the destination looks "on the way".
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq))
const cx = ax + t * dx
const cy = ay + t * dy
return { distanceKm: Math.hypot(px - cx, py - cy), t }
}
export interface CorridorHit {
/** Kilometres from the route line at its closest point. */
offRouteKm: number
/** Kilometres travelled along the route before reaching that closest point. */
alongKm: number
}
/**
* Where a point sits relative to the route: how far off it is, and how far into the
* drive it comes. The second number is what puts "lunch" before "the hotel" in a list
* instead of sorting by name.
*/
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
if (line.length < 2) return null
let best = Number.POSITIVE_INFINITY
let bestAlong = 0
let travelled = 0
for (let i = 0; i < line.length - 1; i++) {
const a = line[i]
const b = line[i + 1]
const segment = haversineKm(a, b)
const { distanceKm: d, t } = projectOnSegment(p, a, b)
if (d < best) {
best = d
bestAlong = travelled + t * segment
}
travelled += segment
}
return { offRouteKm: best, alongKm: bestAlong }
}
/**
* Bounding boxes covering the route, each no larger than `maxSpanDeg`.
*
* The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not
* asked to scan a continent, which means a long drive has to be cut into boxes here
* rather than sent as one. Boxes are grown along the route until one would exceed the
* span, so a straight motorway needs far fewer requests than a mountain road.
*/
/**
* Splits a step that is longer than a box into even pieces.
*
* The simplifier is right to reduce a dead straight motorway stretch to its two ends,
* but tiling walks point to point: two ends 100 km apart produce a box at each end and
* nothing in between, so the middle of the drive would never be searched.
*/
function densify(line: LatLng[], maxStepDeg: number): LatLng[] {
if (line.length < 2) return line
const out: LatLng[] = [line[0]]
for (let i = 1; i < line.length; i++) {
const a = line[i - 1]
const b = line[i]
const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng))
const steps = Math.max(1, Math.ceil(span / maxStepDeg))
for (let s = 1; s <= steps; s++) {
out.push({
lat: a.lat + ((b.lat - a.lat) * s) / steps,
lng: a.lng + ((b.lng - a.lng) * s) / steps,
})
}
}
return out
}
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
if (line.length === 0) return []
const padLat = widthKm / KM_PER_DEG_LAT
const tiles: Bbox[] = []
let current: Bbox | null = null
const padded = (b: Bbox): Bbox => {
// Longitude degrees shrink towards the poles, so the padding has to grow with
// latitude or the corridor is narrower than asked for in the north.
const midLat = (b.north + b.south) / 2
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)))
return {
south: b.south - padLat,
north: b.north + padLat,
west: b.west - padLng,
east: b.east + padLng,
}
}
// The point the open box last swallowed. When a box closes, the next one starts
// from it rather than from `p` alone, so the two overlap by exactly one step of
// the walk. Starting at `p` left the stretch between them covered only by the
// padding on either side — and the walk steps in up to maxSpanDeg/2 while the
// padding is widthKm, so at the offered widths a band of several kilometres of
// route fell into no box at all. Nothing downstream noticed: the panel counts
// tiles asked and tiles answered, so it reported a finished, uncapped,
// error-free search of a drive it had never fully looked at. "Find fuel along
// the route" is the whole feature.
let previous: LatLng | null = null
for (const p of densify(line, maxSpanDeg / 2)) {
if (!current) {
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
previous = p
continue
}
const next: Bbox = {
south: Math.min(current.south, p.lat),
north: Math.max(current.north, p.lat),
west: Math.min(current.west, p.lng),
east: Math.max(current.east, p.lng),
}
const grown = padded(next)
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
tiles.push(padded(current))
const from = previous ?? p
current = {
south: Math.min(from.lat, p.lat),
north: Math.max(from.lat, p.lat),
west: Math.min(from.lng, p.lng),
east: Math.max(from.lng, p.lng),
}
} else {
current = next
}
previous = p
}
if (current) tiles.push(padded(current))
return tiles
}
/**
* Thins a route down to the points that still describe it, so the corridor is built
* from tens of coordinates rather than the thousands a routed geometry carries.
* Ramer-Douglas-Peucker with the tolerance expressed in kilometres.
*/
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
if (line.length < 3) return line.slice()
let maxDist = 0
let index = 0
const first = line[0]
const last = line[line.length - 1]
for (let i = 1; i < line.length - 1; i++) {
const d = distanceToSegmentKm(line[i], first, last)
if (d > maxDist) {
maxDist = d
index = i
}
}
if (maxDist <= toleranceKm) return [first, last]
const left = simplifyLine(line.slice(0, index + 1), toleranceKm)
const right = simplifyLine(line.slice(index), toleranceKm)
return [...left.slice(0, -1), ...right]
}
@@ -1,238 +0,0 @@
import { describe, it, expect } from 'vitest'
import { haversineKm, type LatLng } from './corridor'
import {
parseTrack, lineLengthKm, thin, nearestIndex, orient, stopIndices, trackGapKm, planTrack,
} from './followTrack'
/**
* The refinement is the interesting part, and it is testable without a router: what it
* needs is something that answers "the road I would drive through these waypoints", and
* the straight line between them is exactly the answer that makes the loop work for its
* living. A track that bulges away from that chord is the shape every scenic route has.
*/
/** A track arching north away from the straight line between its ends. */
function arc(points = 120, bulgeDeg = 0.1): LatLng[] {
return Array.from({ length: points + 1 }, (_, i) => ({
lat: 52 + bulgeDeg * Math.sin((Math.PI * i) / points),
lng: 13 + i / points,
}))
}
const START: LatLng = { lat: 52, lng: 13 }
const END: LatLng = { lat: 52, lng: 14 }
/** A router with no idea about roads: it drives straight from waypoint to waypoint. */
function straightRouter(): { route: (w: LatLng[]) => Promise<LatLng[]>; calls: LatLng[][] } {
const calls: LatLng[][] = []
return {
calls,
route: async (waypoints: LatLng[]) => {
calls.push(waypoints)
return waypoints
},
}
}
describe('parseTrack', () => {
it('FE-FOLLOWTRACK-001: reads pairs and triples, and steps over what is neither', () => {
const geometry = JSON.stringify([[52, 13], [52.1, 13.1, 340], ['x', 1], [1], null, [53, 14]])
expect(parseTrack(geometry)).toEqual([
{ lat: 52, lng: 13 },
{ lat: 52.1, lng: 13.1 },
{ lat: 53, lng: 14 },
])
})
it('FE-FOLLOWTRACK-002: a track that is not a track is nothing, never a throw', () => {
// Somebody else's column, filled by an importer that has seen every malformed GPX
// there is. A day that cannot follow a track is a day; an exception here is a page.
expect(parseTrack('not json')).toEqual([])
expect(parseTrack('{"type":"LineString"}')).toEqual([])
expect(parseTrack(null)).toEqual([])
expect(parseTrack(undefined)).toEqual([])
})
})
describe('thinning and measuring', () => {
it('FE-FOLLOWTRACK-003: thinning keeps both ends, whatever the stride', () => {
const line = arc(1000)
const thinned = thin(line, 400)
expect(thinned.length).toBeLessThanOrEqual(401)
expect(thinned[0]).toEqual(line[0])
expect(thinned[thinned.length - 1]).toEqual(line[line.length - 1])
})
it('FE-FOLLOWTRACK-004: a line shorter than the cap comes back untouched', () => {
const line = arc(10)
expect(thin(line, 400)).toBe(line)
})
it('FE-FOLLOWTRACK-005: an arc is longer than the chord it spans', () => {
expect(lineLengthKm(arc())).toBeGreaterThan(haversineKm(START, END))
})
it('FE-FOLLOWTRACK-006: the nearest point of a track is found by position, not by order', () => {
const line = arc(20)
expect(nearestIndex(line, line[7])).toBe(7)
})
})
describe('orientation', () => {
it('FE-FOLLOWTRACK-007: a track recorded the other way round is turned', () => {
// Half of Norway's scenic routes are published southbound. Without this the vias come
// out in reverse order and the day drives the loop backwards.
const backwards = [...arc(20)].reverse()
const turned = orient(backwards, [START, END])
expect(turned[0].lng).toBeLessThan(turned[turned.length - 1].lng)
})
it('FE-FOLLOWTRACK-008: a track already pointing the right way is left alone', () => {
const line = arc(20)
expect(orient(line, [START, END])).toBe(line)
})
it('FE-FOLLOWTRACK-009: stops never walk backwards along the track', () => {
// A track that doubles back has two nearest points for the same stop, and the wrong
// one would put a via on the leg before it.
const line = arc(40)
const stops = [START, { lat: 52.05, lng: 13.5 }, END]
const idx = stopIndices(line, stops)
expect(idx[0]).toBeLessThanOrEqual(idx[1])
expect(idx[1]).toBeLessThanOrEqual(idx[2])
})
})
describe('planTrack', () => {
it('FE-FOLLOWTRACK-010: a road that already follows the track needs no vias', async () => {
const track = arc()
const plan = await planTrack({ track, stops: [START, END], route: async () => track })
expect(plan.vias).toEqual([])
expect(plan.rounds).toBe(1)
expect(plan.capped).toBe(false)
// Still names the leg it runs along, so applying it clears what an earlier pass left.
expect(plan.legs).toEqual([0])
})
it('FE-FOLLOWTRACK-011: it refines until the road is inside the tolerance', async () => {
const { route, calls } = straightRouter()
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 1 } })
expect(plan.capped).toBe(false)
expect(plan.strayKm).toBeLessThanOrEqual(1)
expect(plan.vias.length).toBeGreaterThan(0)
// One route per round, and the waypoint list grows by exactly the via just added.
expect(calls.length).toBe(plan.rounds)
expect(calls[calls.length - 1].length).toBe(2 + plan.vias.length)
})
it('FE-FOLLOWTRACK-012: every via lands on the track, in driving order', async () => {
const { route } = straightRouter()
const track = arc()
const plan = await planTrack({ track, stops: [START, END], route, options: { toleranceKm: 1 } })
for (const via of plan.vias) {
expect(track.some(p => haversineKm(p, via) < 0.001)).toBe(true)
}
const longitudes = plan.vias.map(v => v.lng)
expect([...longitudes].sort((a, b) => a - b)).toEqual(longitudes)
})
it('FE-FOLLOWTRACK-013: no via is laid down on top of a stop', async () => {
// A via a hundred metres from a stop costs a waypoint to tell the router what the
// stop already told it.
const { route } = straightRouter()
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 0.2, maxVias: 8 } })
for (const via of plan.vias) {
expect(haversineKm(via, START)).toBeGreaterThanOrEqual(0.6)
expect(haversineKm(via, END)).toBeGreaterThanOrEqual(0.6)
}
})
it('FE-FOLLOWTRACK-014: a via hangs behind the stop whose leg it shapes', async () => {
const { route } = straightRouter()
// Three stops, and the bulge is entirely on the second leg.
const straight: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 52, lng: 13 + i / 78 }))
const bulge: LatLng[] = Array.from({ length: 81 }, (_, i) => ({
lat: 52 + 0.1 * Math.sin((Math.PI * i) / 80),
lng: 13.5 + i / 160,
}))
const middle: LatLng = { lat: 52, lng: 13.5 }
const plan = await planTrack({
track: [...straight, ...bulge],
stops: [START, middle, END],
route,
options: { toleranceKm: 1 },
})
expect(plan.vias.length).toBeGreaterThan(0)
expect(plan.vias.every(v => v.after_order_index === 1)).toBe(true)
expect(plan.legs).toEqual([0, 1])
})
it('FE-FOLLOWTRACK-015: it gives up rather than spending the day on a road that will not fit', async () => {
const { route, calls } = straightRouter()
const plan = await planTrack({
track: arc(),
stops: [START, END],
route,
options: { toleranceKm: 0.0001, maxVias: 3 },
})
expect(plan.capped).toBe(true)
expect(plan.vias).toHaveLength(3)
expect(calls.length).toBe(4)
})
it('FE-FOLLOWTRACK-016: a track the day only brushes past is not a route for it', async () => {
const { route, calls } = straightRouter()
const plan = await planTrack({ track: [{ lat: 40, lng: 2 }, { lat: 40.001, lng: 2.001 }], stops: [START, END], route })
expect(plan.vias).toEqual([])
expect(plan.legs).toEqual([])
// Never asks the router about a track that has nothing to do with the day.
expect(calls).toHaveLength(0)
})
it('FE-FOLLOWTRACK-017: nothing to route through is not an error', async () => {
const { route } = straightRouter()
expect((await planTrack({ track: arc(), stops: [START], route })).vias).toEqual([])
expect((await planTrack({ track: [], stops: [START, END], route })).vias).toEqual([])
})
it('FE-FOLLOWTRACK-018: a router that answers with no road says so', async () => {
await expect(planTrack({ track: arc(), stops: [START, END], route: async () => [] }))
.rejects.toThrow(/road/)
})
it('FE-FOLLOWTRACK-019: an aborted plan stops where it is', async () => {
const controller = new AbortController()
const plan = await planTrack({
track: arc(),
stops: [START, END],
route: async () => [START, END],
signal: controller.signal,
options: { toleranceKm: 0.001 },
})
controller.abort()
// Ran to its own conclusion here; the point is that the signal is honoured at the top
// of each round, which the aborted-before-start case proves.
expect(plan.rounds).toBeGreaterThan(0)
const stopped = new AbortController()
stopped.abort()
const none = await planTrack({
track: arc(),
stops: [START, END],
route: async () => [START, END],
signal: stopped.signal,
})
expect(none.rounds).toBe(0)
})
it('FE-FOLLOWTRACK-020: how far a track runs from a day is what orders a list of them', async () => {
const near = trackGapKm(arc(), [START, END])
const far = trackGapKm(arc().map(p => ({ lat: p.lat + 3, lng: p.lng })), [START, END])
expect(near).toBeLessThan(far)
expect(trackGapKm([], [START])).toBe(Number.POSITIVE_INFINITY)
})
})
@@ -1,318 +0,0 @@
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
/**
* Making a day's drive follow an imported track.
*
* A GPX track is a shape, not a route: a list of positions with no idea which roads they
* were recorded on. Drawing it beside the day is easy and useless — the distance, the
* drive time and every warning still belong to the motorway the router picked, so the
* plan says four hours while the road nobody is taking says seven. Norway publishes its
* scenic routes this way, and so does every touring club worth importing from.
*
* What turns a shape into a route the day actually takes is a handful of vias placed on
* it, because a via is the one thing a router treats as "go through here". Placing them
* is the whole problem. Every point of the track would route exactly and take a hundred
* requests; two would route in seconds and follow nothing.
*
* So this refines: route through what we have, find the point of the track the road
* misses by the most, make that point a via, and go again. Each round roughly halves the
* worst miss, because the new via lands in the middle of whatever the last one left
* unfollowed — which is why a scenic route usually fits inside ten of them, and why the
* loop stops when the road is close enough rather than after a fixed count.
*
* Everything here is pure apart from the injected router: no React, no store, no api.
*/
/** Track points kept for measuring — enough to catch a hairpin, few enough to be fast. */
const MEASURE_POINTS = 400
/** Routed points kept for measuring. Coarser: it is the thing being measured against. */
const ROUTE_POINTS = 2000
/**
* How close a via may sit to a stop, or to another via, before it is pointless.
*
* A via a hundred metres from a stop tells the router nothing it did not already know
* from the stop, and two vias in the same lay-by cost a waypoint each to say one thing.
*/
const MIN_GAP_KM = 0.6
export interface FollowTrackOptions {
/** How close the road must come to the track before the plan is done, in kilometres. */
toleranceKm?: number
/** The most vias the plan may lay down. */
maxVias?: number
/** The most routing round trips it may spend. */
maxRounds?: number
}
export interface TrackVia {
/** The stop the via hangs behind, as an index into the day's stops. */
after_order_index: number
lat: number
lng: number
}
export interface FollowTrackPlan {
/** The vias to write, in driving order, each already on the leg it shapes. */
vias: TrackVia[]
/** The legs the track runs along — cleared first, so applying twice does not stack. */
legs: number[]
/** How far the road still strays from the track at its worst point, in kilometres. */
strayKm: number
/** Routing round trips spent. */
rounds: number
/** True when it ran out of vias or rounds before the road fitted the track. */
capped: boolean
}
const EMPTY_PLAN: FollowTrackPlan = { vias: [], legs: [], strayKm: 0, rounds: 0, capped: false }
/**
* The points of a stored track.
*
* `route_geometry` is the column the GPX and KML importers write, holding `[lat, lng]`
* pairs or `[lat, lng, ele]` triples depending on whether every point carried an
* elevation. Anything else in there is somebody else's data — a malformed track is worth
* skipping, never worth throwing over.
*/
export function parseTrack(routeGeometry: string | null | undefined): LatLng[] {
if (!routeGeometry) return []
try {
const raw: unknown = JSON.parse(routeGeometry)
if (!Array.isArray(raw)) return []
const out: LatLng[] = []
for (const point of raw) {
if (!Array.isArray(point) || point.length < 2) continue
const lat = Number(point[0])
const lng = Number(point[1])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
out.push({ lat, lng })
}
return out
} catch {
return []
}
}
/** How long a polyline is, in kilometres. */
export function lineLengthKm(line: LatLng[]): number {
let km = 0
for (let i = 1; i < line.length; i++) km += haversineKm(line[i - 1], line[i])
return km
}
/**
* Every nth point, keeping both ends.
*
* Deliberately not the Douglas-Peucker in `corridor.ts`: that one is recursive and slices
* the array at every step, which is fine for a routed leg and quadratic on a recorded
* track of thirty thousand points. Taking a stride loses a little accuracy on a hairpin
* and none of the shape, and the tolerance this feeds is measured in kilometres.
*/
export function thin(line: LatLng[], max: number): LatLng[] {
if (line.length <= max) return line
const stride = Math.ceil(line.length / max)
const out: LatLng[] = []
for (let i = 0; i < line.length; i += stride) out.push(line[i])
const last = line[line.length - 1]
if (out[out.length - 1] !== last) out.push(last)
return out
}
/** Index of the point of `line` closest to `p`. */
export function nearestIndex(line: LatLng[], p: LatLng): number {
let best = Number.POSITIVE_INFINITY
let at = 0
for (let i = 0; i < line.length; i++) {
const d = haversineKm(line[i], p)
if (d < best) {
best = d
at = i
}
}
return at
}
/** How far a point lies from a line, in kilometres. Infinite for a line of one point. */
export function offLineKm(line: LatLng[], p: LatLng): number {
return projectOntoRoute(p, line)?.offRouteKm ?? Number.POSITIVE_INFINITY
}
/**
* The track pointing the way the day is driven.
*
* A recorded track has a direction, and it is whichever way the person who recorded it
* happened to drive. Half of Norway's scenic routes are published southbound. Reversed
* where the day's last stop comes before its first, so everything downstream can treat a
* higher index as "further along the drive".
*/
export function orient(track: LatLng[], stops: LatLng[]): LatLng[] {
if (track.length < 2 || stops.length < 2) return track
const start = nearestIndex(track, stops[0])
const end = nearestIndex(track, stops[stops.length - 1])
return end < start ? [...track].reverse() : track
}
/**
* Where each stop sits on the track, as an index.
*
* Forced not to go backwards. A stop nowhere near the track still gets its nearest point,
* and on a track that doubles back that point can land before the previous stop's —
* which would put a via on the wrong leg and send the day round the loop twice.
*/
export function stopIndices(track: LatLng[], stops: LatLng[]): number[] {
let floor = 0
return stops.map(stop => {
const at = Math.max(floor, nearestIndex(track, stop))
floor = at
return at
})
}
/** The leg an anchor belongs to: the last stop it comes after. */
function legFor(anchor: number, stopIdx: number[]): number {
let leg = 0
for (let k = 0; k + 1 < stopIdx.length; k++) if (stopIdx[k] <= anchor) leg = k
return leg
}
/** The stops with the anchors threaded in between, in the order they are driven. */
function waypointsFor(stops: LatLng[], anchors: number[], track: LatLng[], stopIdx: number[]): LatLng[] {
const out: LatLng[] = []
for (let k = 0; k < stops.length; k++) {
out.push(stops[k])
if (k + 1 >= stops.length) continue
for (const anchor of anchors) if (legFor(anchor, stopIdx) === k) out.push(track[anchor])
}
return out
}
/**
* The point of the track the road misses by the most — among the ones worth fixing.
*
* Points already anchored are skipped because the router is going through them, and
* points hard against a stop or an existing via are skipped because a via there would
* buy nothing. What is left is the miss the next round can actually close.
*/
function worstMiss(
track: LatLng[],
road: LatLng[],
span: { from: number; to: number },
anchors: number[],
stops: LatLng[],
): { index: number; km: number } | null {
const taken = new Set(anchors)
let best: { index: number; km: number } | null = null
for (let i = span.from; i <= span.to; i++) {
if (taken.has(i)) continue
const point = track[i]
if (stops.some(s => haversineKm(s, point) < MIN_GAP_KM)) continue
if (anchors.some(a => haversineKm(track[a], point) < MIN_GAP_KM)) continue
const km = offLineKm(road, point)
if (!Number.isFinite(km)) continue
if (!best || km > best.km) best = { index: i, km }
}
return best
}
export interface TrackPlanInput {
/** The track's points, as stored. */
track: LatLng[]
/** The day's stops, in driving order. */
stops: LatLng[]
/** Routes through the given waypoints and answers with the road it drew. */
route: (waypoints: LatLng[]) => Promise<LatLng[]>
options?: FollowTrackOptions
/** Called after each round, so a dialog can say how far it has got. */
onRound?: (round: number, strayKm: number) => void
signal?: AbortSignal
}
/**
* How much of a day a track has anything to say about.
*
* A trip full of imported tracks needs an order to offer them in, and "how far this one
* runs from that drive" is the answer — measured from the stops rather than from the
* routed line, so it holds for a day that has not routed yet.
*/
export function trackGapKm(track: LatLng[], stops: LatLng[]): number {
if (!track.length || !stops.length) return Number.POSITIVE_INFINITY
const line = thin(track, MEASURE_POINTS)
if (line.length < 2) return haversineKm(line[0], stops[0])
let best = Number.POSITIVE_INFINITY
for (const stop of stops) best = Math.min(best, offLineKm(line, stop))
return best
}
/**
* Vias that pull a day's drive onto a track.
*
* Routes the whole day per round rather than one leg at a time: the day is a single
* request to begin with, and asking per leg would multiply every round by the number of
* legs against a host that answers one request a second.
*/
export async function planTrack({
track,
stops,
route,
options = {},
onRound,
signal,
}: TrackPlanInput): Promise<FollowTrackPlan> {
const toleranceKm = options.toleranceKm ?? 1
const maxVias = options.maxVias ?? 12
const maxRounds = options.maxRounds ?? 10
if (track.length < 2 || stops.length < 2) return EMPTY_PLAN
const measured = orient(thin(track, MEASURE_POINTS), stops)
const stopIdx = stopIndices(measured, stops)
const span = { from: stopIdx[0], to: stopIdx[stopIdx.length - 1] }
// A track the day only touches at one point is a track for some other day.
if (span.to - span.from < 2) return EMPTY_PLAN
// The legs the track runs along. Read off the stops, not off the anchors, so applying
// a track twice clears what the first pass laid down instead of stacking on it — and
// so a track that already fits still says which legs it owns.
const legs: number[] = []
for (let k = 0; k + 1 < stops.length; k++) {
if (stopIdx[k + 1] > span.from && stopIdx[k] < span.to) legs.push(k)
}
const anchors: number[] = []
let strayKm = 0
let rounds = 0
let capped = false
for (;;) {
if (signal?.aborted) break
const drawn = await route(waypointsFor(stops, anchors, measured, stopIdx))
if (drawn.length < 2) throw new Error('The router answered without a road')
rounds += 1
const miss = worstMiss(measured, thin(drawn, ROUTE_POINTS), span, anchors, stops)
// No candidate left to fix is as finished as being inside the tolerance: everything
// that remains sits against a stop or against a via that is already there.
strayKm = miss?.km ?? 0
onRound?.(rounds, strayKm)
if (!miss || miss.km <= toleranceKm) break
if (anchors.length >= maxVias || rounds >= maxRounds) {
capped = true
break
}
anchors.push(miss.index)
anchors.sort((a, b) => a - b)
}
return {
vias: anchors.map(i => ({
after_order_index: legFor(i, stopIdx),
lat: measured[i].lat,
lng: measured[i].lng,
})),
legs,
strayKm,
rounds,
capped,
}
}
@@ -1,177 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
reanchorAfterInsert,
reanchorAfterRemove,
reanchorByStopOrder,
reanchorAfterReorder,
isEmptyReanchoring,
type AnchoredVia,
} from './roadtripModel'
/**
* A via lives at a position in the day's stop list, and that position is resolved fresh
* every time the day is routed. These tests pin what happens to it when the list moves
* under it — the arithmetic that decides whether a reshaped drive survives adding a
* petrol station to it.
*/
const via = (id: number, after: number, lat = 53, lng = 10): AnchoredVia =>
({ id, after_order_index: after, lat, lng })
/** The anchors as a plain map, so a test reads as "which via ended up where". */
const anchors = (moved: { id: number; after_order_index: number }[]) =>
Object.fromEntries(moved.map(v => [v.id, v.after_order_index]))
describe('reanchorAfterInsert', () => {
it('FE-REANCHOR-001: a stop added past a via leaves it alone', () => {
// Legs 0,1,2 — the via is on leg 0 and the new stop lands at position 3.
const plan = reanchorAfterInsert([via(1, 0)], 3, () => true)
expect(isEmptyReanchoring(plan)).toBe(true)
})
it('FE-REANCHOR-002: a stop added before a via pushes it one leg later', () => {
const plan = reanchorAfterInsert([via(1, 2), via(2, 3)], 1, () => true)
expect(anchors(plan.vias)).toEqual({ 1: 3, 2: 4 })
expect(plan.remove).toEqual([])
})
it('FE-REANCHOR-003: on the split leg, a via before the new stop keeps its leg', () => {
// The reported bug: Hamburg → [via] → Berlin, and a fuel stop lands between them at
// position 1. The via is passed before the fuel stop, so it belongs to the first half.
const plan = reanchorAfterInsert([via(1, 0)], 1, () => true)
expect(isEmptyReanchoring(plan)).toBe(true)
})
it('FE-REANCHOR-004: on the split leg, a via past the new stop moves to the second half', () => {
// Same day, but the via sits beyond the fuel stop. Left at 0 it would bend the short
// first half back on itself and leave the long second half unshaped — which is the
// route snapping back to the old road.
const plan = reanchorAfterInsert([via(1, 0)], 1, () => false)
expect(anchors(plan.vias)).toEqual({ 1: 1 })
})
it('FE-REANCHOR-005: two vias on one split leg are told apart, not moved as a block', () => {
const early = via(1, 1)
const late = via(2, 1)
const plan = reanchorAfterInsert([early, late], 2, v => v.id === early.id)
expect(anchors(plan.vias)).toEqual({ 2: 2 })
})
it('FE-REANCHOR-006: inserting at the very front splits nothing and shifts everything', () => {
const plan = reanchorAfterInsert([via(1, 0), via(2, 1)], 0, () => {
throw new Error('nothing is split when the new stop leads the day')
})
expect(anchors(plan.vias)).toEqual({ 1: 1, 2: 2 })
})
it('FE-REANCHOR-007: appending at the end leaves every existing leg where it was', () => {
// Four stops, legs 0..2, appended as the fifth.
const plan = reanchorAfterInsert([via(1, 0), via(2, 2)], 4, () => true)
expect(isEmptyReanchoring(plan)).toBe(true)
})
it('FE-REANCHOR-008: no vias means no request', () => {
expect(isEmptyReanchoring(reanchorAfterInsert([], 2, () => true))).toBe(true)
})
})
describe('reanchorAfterRemove', () => {
it('FE-REANCHOR-009: the legs either side of a removed stop become one', () => {
// Five stops, legs 0..3. Stop 2 goes; legs 1 and 2 merge into leg 1.
const plan = reanchorAfterRemove([via(1, 1), via(2, 2), via(3, 3)], 2, 5)
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
expect(plan.remove).toEqual([])
})
it('FE-REANCHOR-010: removing the first stop drops the vias on the leg leaving it', () => {
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 0, 4)
expect(plan.remove).toEqual([1])
expect(anchors(plan.vias)).toEqual({ 2: 1 })
})
it('FE-REANCHOR-011: removing the last stop drops the vias on the leg into it', () => {
// Four stops, legs 0..2. Stop 3 goes, so leg 2 has nothing to lead to.
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 3, 4)
expect(plan.remove).toEqual([2])
expect(anchors(plan.vias)).toEqual({})
})
it('FE-REANCHOR-012: a day left with one stop has no drive, so no via has a leg', () => {
const plan = reanchorAfterRemove([via(1, 0)], 1, 2)
expect(plan.remove).toEqual([1])
expect(plan.vias).toEqual([])
})
})
describe('reanchorAfterReorder', () => {
it('FE-REANCHOR-013: moving a stop later pulls the vias between along with it', () => {
// Five stops, legs 0..3. Stop 1 moves to position 3.
const plan = reanchorAfterReorder([via(1, 0), via(2, 2), via(3, 3)], 1, 3, 5)
// Legs 0 and 1 merge into 0, then the stop is put back at 3, splitting leg 2.
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
})
it('FE-REANCHOR-014: moving a stop back to where it was changes nothing', () => {
expect(isEmptyReanchoring(reanchorAfterReorder([via(1, 1)], 2, 2, 4))).toBe(true)
})
it('FE-REANCHOR-015: a via whose leg vanishes in the move is dropped, not left dangling', () => {
// Moving the first stop kills the leg leaving it, and nothing later re-creates that
// via's anchor — reporting it for deletion is the only honest answer.
const plan = reanchorAfterReorder([via(1, 0)], 0, 2, 4)
expect(plan.remove).toEqual([1])
})
it('FE-REANCHOR-016: no vias means no request', () => {
expect(isEmptyReanchoring(reanchorAfterReorder([], 0, 2, 4))).toBe(true)
})
it('FE-REANCHOR-017: swapping the only two stops keeps every via', () => {
// A reorder removes nothing. Leg 0 is still leg 0 after the swap, merely
// driven the other way round, so the anchors are already right. Routing this
// through the removal half asked the server to delete every via of the day —
// and two stops is exactly the shape "follow this track" produces, up to
// nine vias on that one leg, gone to a single drag with no prompt and no
// undo.
const vias = [via(11, 0), via(12, 0), via(13, 0)]
for (const [from, to] of [[0, 1], [1, 0]] as const) {
const plan = reanchorAfterReorder(vias, from, to, 2)
expect(plan.remove, `${from}->${to}`).toEqual([])
expect(isEmptyReanchoring(plan), `${from}->${to}`).toBe(true)
}
})
it('FE-REANCHOR-019: a wholesale reorder follows the stop, not the number', () => {
// The plan-mode drag hands a whole new ordering rather than one move, so the
// anchors are mapped by the stop they were pinned behind. Stops A,B,C,D with
// a via on the C->D leg (anchor 2): move A to the end and C is now at index
// 1, so the via belongs on leg 1.
const plan = reanchorByStopOrder([via(1, 2)], [10, 20, 30, 40], [20, 30, 40, 10])
expect(plan.vias).toEqual([{ id: 1, after_order_index: 1 }])
expect(plan.remove).toEqual([])
})
it('FE-REANCHOR-020: a via whose stop became the last one has no leg left', () => {
// Nothing follows the final stop, so there is no drive for the via to bend.
const plan = reanchorByStopOrder([via(1, 0)], [10, 20, 30], [20, 30, 10])
expect(plan.remove).toEqual([1])
})
it('FE-REANCHOR-021: a stop that is gone takes its vias with it', () => {
const plan = reanchorByStopOrder([via(1, 1)], [10, 20, 30], [10, 30])
expect(plan.remove).toEqual([1])
})
it('FE-REANCHOR-022: an ordering that did not actually change asks for nothing', () => {
expect(isEmptyReanchoring(reanchorByStopOrder([via(1, 0)], [10, 20, 30], [10, 20, 30]))).toBe(true)
})
it('FE-REANCHOR-018: removing a stop from a two-stop day still clears the leg', () => {
// The other half of the same rule, and the reason the short-circuit exists:
// take a stop away from a two-stop day and there is no drive left, so a via
// has nowhere to sit.
const plan = reanchorAfterRemove([via(11, 0), via(12, 0)], 1, 2)
expect(plan.remove).toEqual([11, 12])
expect(plan.vias).toEqual([])
})
})
@@ -1,547 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
computeSchedule,
formatClock,
formatDurationShort,
insertIndexForAlong,
deriveDriveWarnings,
legIndexForAlong,
refuelsRange,
sectionAnchors,
parseClock,
splitIntoRuns,
sumLegSeconds,
} from './roadtripModel'
describe('formatDurationShort', () => {
it('prints minutes below an hour', () => {
expect(formatDurationShort(45 * 60)).toBe('45 min')
expect(formatDurationShort(0)).toBe('0 min')
})
it('drops the zero minutes on a whole hour', () => {
expect(formatDurationShort(3600)).toBe('1 h')
expect(formatDurationShort(2 * 3600)).toBe('2 h')
})
it('prints hours and minutes together', () => {
expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min')
})
it('carries instead of printing sixty minutes', () => {
// 1 h 59 min 40 s rounds the minutes to 60, which must become 2 h.
expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h')
})
it('treats nonsense as nothing rather than throwing', () => {
expect(formatDurationShort(Number.NaN)).toBe('0 min')
expect(formatDurationShort(-90)).toBe('0 min')
})
})
describe('parseClock', () => {
it('reads wall-clock times', () => {
expect(parseClock('09:45')).toBe(9 * 60 + 45)
expect(parseClock('9:05')).toBe(9 * 60 + 5)
expect(parseClock('00:00')).toBe(0)
})
it('ignores anything trailing, as stored times sometimes carry seconds', () => {
expect(parseClock('14:30:00')).toBe(14 * 60 + 30)
})
it('rejects what is not a time', () => {
expect(parseClock(null)).toBeNull()
expect(parseClock('')).toBeNull()
expect(parseClock('later')).toBeNull()
expect(parseClock('25:00')).toBeNull()
expect(parseClock('12:75')).toBeNull()
})
})
describe('formatClock', () => {
it('pads both halves', () => {
expect(formatClock(9 * 60 + 5)).toBe('09:05')
expect(formatClock(0)).toBe('00:00')
})
it('wraps past midnight instead of printing a 25th hour', () => {
expect(formatClock(25 * 60)).toBe('01:00')
expect(formatClock(-30)).toBe('23:30')
})
})
describe('computeSchedule', () => {
const hours = (h: number): number => h * 3600
it('walks arrival and departure forward from the first pinned time', () => {
// The example from discussion #1797: leave at 8:00, drive 1 h 45, stay 2 h, drive 1 h 10.
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 120 },
{ anchor: null, dwellMinutes: 45 },
],
[hours(1.75), hours(1) + 10 * 60],
)
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true })
expect(entries[1]).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false })
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
})
it('leaves stops before the first pinned time blank rather than inventing one', () => {
const { entries } = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
{ anchor: '10:00', dwellMinutes: 30 },
],
[hours(1)],
)
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('restarts the cascade at a pinned stop instead of pushing it', () => {
// The drive would arrive at 09:00, but the museum ticket says 11:00: everything
// after it counts from 11:00, not from the drive.
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: '11:00', dwellMinutes: 60 },
{ anchor: null, dwellMinutes: 0 },
],
[hours(1), hours(1)],
)
expect(entries[1].arrival).toBe('11:00')
expect(entries[2].arrival).toBe('13:00')
})
it('ROADTRIP-MODEL-082: a night drive onto a pinned time after midnight is on time', () => {
// Leave at 20:00, drive six hours, check in at 02:00. The anchor used to be
// placed on the day the cascade had reached so far — and that only advances
// once a computed arrival crosses midnight, which is read before this stop's
// arrival is known. So the stop that does the crossing had its own pin put a
// whole day early: the rail drew a warning-coloured "+24 h" on a plan that
// was exactly on time, and dropped the overnight marker with it.
const { entries, warnings } = computeSchedule(
[
{ anchor: '20:00', dwellMinutes: 0 },
{ anchor: '02:00', dwellMinutes: 0 },
],
[hours(6)],
)
expect(warnings.filter(w => w.code === 'late')).toEqual([])
expect(entries[1]).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true })
// The day still turned over, so the rail keeps its carry badge.
expect(warnings.some(w => w.code === 'overnight' && w.index === 1)).toBe(true)
})
it('ROADTRIP-MODEL-083: a ferry that really is missed is still reported late', () => {
// The other half: picking the nearest occurrence must not swallow a genuine
// delay. Leaving at 21:00 and driving five hours arrives at 02:00, half an
// hour after the 01:30 ferry, and that is what it says.
const { warnings } = computeSchedule(
[
{ anchor: '21:00', dwellMinutes: 0 },
{ anchor: '01:30', dwellMinutes: 0 },
],
[hours(5)],
)
expect(warnings.find(w => w.code === 'late')).toMatchObject({ index: 1, minutes: 30 })
})
it('ROADTRIP-MODEL-084: a pinned stop after an untimed leg keeps the day it is on', () => {
// Nothing to be late against, but the day carried so far still applies —
// otherwise the stop prints under a day-1 stop as though it happened first.
const { entries } = computeSchedule(
[
{ anchor: '20:00', dwellMinutes: 0 },
{ anchor: '02:00', dwellMinutes: 0 },
{ anchor: '09:00', dwellMinutes: 0 },
],
[hours(6), undefined],
)
expect(entries[2]).toMatchObject({ arrival: '09:00', dayOffset: 1 })
})
it('reports a stop the drive cannot reach in time', () => {
const { warnings } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: '09:00', dwellMinutes: 0 },
],
[hours(3)],
)
expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }])
})
it('does not cry about a minute of rounding', () => {
const { warnings } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: '09:00', dwellMinutes: 0 },
],
[hours(1) + 30],
)
expect(warnings).toEqual([])
})
it('breaks the chain at a leg that never routed', () => {
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 30 },
{ anchor: null, dwellMinutes: 30 },
{ anchor: null, dwellMinutes: 0 },
],
[undefined, hours(1)],
)
expect(entries[0].arrival).toBe('08:00')
expect(entries[1].arrival).toBeNull()
expect(entries[2].arrival).toBeNull()
})
it('flags the day rolling past midnight and keeps counting', () => {
const { entries, warnings } = computeSchedule(
[
{ anchor: '22:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 0 },
],
[hours(4)],
)
expect(entries[1].arrival).toBe('02:00')
expect(entries[1].dayOffset).toBe(1)
expect(warnings).toEqual([{ index: 1, code: 'overnight' }])
})
it('handles an empty chain', () => {
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] })
})
it('treats a missing dwell as no time spent', () => {
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: null },
{ anchor: null, dwellMinutes: null },
],
[hours(1)],
)
expect(entries[0].departure).toBe('08:00')
expect(entries[1].arrival).toBe('09:00')
})
})
describe('splitIntoRuns', () => {
const drive = (): string => 'driving'
it('asks for a whole day travelled one way in a single run', () => {
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive)
expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }])
})
it('splits where the travel mode changes, and repeats the stop on both sides', () => {
// Drive to b, walk to c, drive on to d: the walk is its own request, and b and c
// each belong to two runs because they are the ends of neighbouring legs.
const modes = ['driving', 'walking', 'driving']
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)])
expect(runs).toEqual([
{ stops: ['a', 'b'], mode: 'driving' },
{ stops: ['b', 'c'], mode: 'walking' },
{ stops: ['c', 'd'], mode: 'driving' },
])
})
it('keeps consecutive legs of the same mode together across a change and back', () => {
const modes = ['driving', 'driving', 'walking', 'walking']
const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)])
expect(runs).toEqual([
{ stops: ['a', 'b', 'c'], mode: 'driving' },
{ stops: ['c', 'd', 'e'], mode: 'walking' },
])
})
it('has nothing to ask for when there is no leg', () => {
expect(splitIntoRuns([], drive)).toEqual([])
expect(splitIntoRuns(['a'], drive)).toEqual([])
})
})
describe('sumLegSeconds', () => {
it('adds only the legs that routed', () => {
expect(sumLegSeconds([600, undefined, 1200])).toBe(1800)
expect(sumLegSeconds([])).toBe(0)
})
})
describe('insertIndexForAlong', () => {
// Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven.
const stops = [0, 50, 290]
it('puts a hit between the two stops it falls between', () => {
expect(insertIndexForAlong(stops, 120)).toBe(2)
expect(insertIndexForAlong(stops, 20)).toBe(1)
})
it('never lands before the stop the day starts from', () => {
expect(insertIndexForAlong(stops, 0)).toBe(1)
// A corridor is wider than the road, so a hit can project just behind the start.
expect(insertIndexForAlong(stops, -3)).toBe(1)
})
it('never lands after the stop the day ends at', () => {
expect(insertIndexForAlong(stops, 290)).toBe(2)
expect(insertIndexForAlong(stops, 400)).toBe(2)
})
it('puts a hit exactly on a stop after it, not before', () => {
expect(insertIndexForAlong(stops, 50)).toBe(2)
})
it('has nowhere to insert on a day that is not a drive', () => {
expect(insertIndexForAlong([], 10)).toBe(0)
expect(insertIndexForAlong([0], 10)).toBe(1)
})
})
describe('legIndexForAlong', () => {
// Against the legs' own lengths rather than by projecting the stops as well: leg
// distances come from the router and are exact, while two stops close together project
// onto each other's stretch and would put a charging halt the wrong side of a town.
const legs = [10_000, 25_000, 40_000]
it('FE-ROADTRIP-MODEL-050: a point inside a leg belongs to that leg', () => {
expect(legIndexForAlong(legs, 0)).toBe(0)
expect(legIndexForAlong(legs, 9_999)).toBe(0)
expect(legIndexForAlong(legs, 10_000)).toBe(1)
expect(legIndexForAlong(legs, 24_999)).toBe(1)
expect(legIndexForAlong(legs, 25_000)).toBe(2)
})
it('FE-ROADTRIP-MODEL-051: a point past the end lands in the last leg, not nowhere', () => {
// A rounding edge, not a missing leg: the projection and the router measure the same
// line two different ways and disagree by metres at the very end.
expect(legIndexForAlong(legs, 40_000)).toBe(2)
expect(legIndexForAlong(legs, 99_999)).toBe(2)
})
it('FE-ROADTRIP-MODEL-052: a day with no routed leg has nowhere to put anything', () => {
expect(legIndexForAlong([], 100)).toBe(-1)
})
})
describe('deriveDriveWarnings', () => {
// Worked through the way a traveller reads it: Hamburg, A1, HEM, Berlin. The badge on
// a stop has to agree with the sum of the drive bands above it, or it looks made up.
it('FE-ROADTRIP-MODEL-069: the figure on a stop is what the bands above it add up to', () => {
const out = deriveDriveWarnings(
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
0,
)
// 21.5 + 103.4 on arrival at HEM, and then it keeps counting, because nothing on this
// trip fills up.
expect(out.warnings).toEqual([
{ index: 2, code: 'range', sinceKm: 125 },
{ index: 3, code: 'range', sinceKm: 370 },
])
})
it('FE-ROADTRIP-MODEL-070: arriving at a petrol station is the plan, not a warning', () => {
// Same trip, but HEM is marked as fuel. Reaching it with an empty tank is exactly
// what it is there for, so nothing is flagged on it; the budget still starts over,
// and the next stretch is measured from there.
const out = deriveDriveWarnings(
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
[false, false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
0,
)
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }])
// 245 and not 370: HEM filled the tank, so Berlin counts from there.
})
const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null }
const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 })
it('FE-ROADTRIP-MODEL-060: no limits set means nothing to report', () => {
const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0)
expect(out.warnings).toEqual([])
expect(out.day).toBeNull()
})
it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it arrives at', () => {
// On arrival, not on departure. Both findings are running totals that only become
// true when you get there, and anchored to the departure the number reads as the
// length of the NEXT leg, which is a different figure and the one that looked wrong.
const out = deriveDriveWarnings(
[leg(100, 90), leg(240, 200)],
[false, false, false],
{ ...noLimits, legMinutes: 180 },
0,
)
expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }])
})
it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => {
const out = deriveDriveWarnings(
[leg(200, 180), leg(200, 180)],
[false, false, false],
{ ...noLimits, dayMinutes: 360 },
0,
)
expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 })
})
it('FE-ROADTRIP-MODEL-063: the figure keeps counting until something actually refuels', () => {
// A warning is not a fill-up. Zeroing the budget at one made every figure after the
// first wrong: the second stop would claim a fresh tank while the same one is still
// in the car.
const out = deriveDriveWarnings(
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
[false, false, false, false, false],
{ ...noLimits, rangeKm: 600 },
0,
)
expect(out.warnings).toEqual([
{ index: 2, code: 'range', sinceKm: 700 },
{ index: 3, code: 'range', sinceKm: 1000 },
{ index: 4, code: 'range', sinceKm: 1400 },
])
})
it('FE-ROADTRIP-MODEL-071: adding a fuel stop is what stops the run of warnings', () => {
// The same drive with a charger at stop 2. Nothing is flagged there, because reaching
// it on an empty tank is what it is for; everything after counts from zero again, so
// the three-warning run above collapses to one.
const out = deriveDriveWarnings(
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
[false, false, true, false, false],
{ ...noLimits, rangeKm: 600 },
0,
)
expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }])
})
it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => {
const stops = [false, true, false]
const out = deriveDriveWarnings(
[leg(60, 500), leg(60, 500)],
stops,
{ ...noLimits, rangeKm: 600 },
0,
)
// The tank is filled at stop 1, so the second 500 km starts from zero and neither leg
// trips the limit. Without the refuel it would be 1000 km on a 600 km range.
expect(out.warnings).toEqual([])
})
it('FE-ROADTRIP-MODEL-065: only fuel and charging refuel', () => {
expect(refuelsRange('fuel')).toBe(true)
expect(refuelsRange('charging')).toBe(true)
// A two-hour lunch fills no tank, and TREK does not know whether the restaurant has a
// charger in its car park.
expect(refuelsRange('restaurant')).toBe(false)
expect(refuelsRange('rest_area')).toBe(false)
expect(refuelsRange('campsite')).toBe(false)
expect(refuelsRange(null)).toBe(false)
})
it('FE-ROADTRIP-MODEL-066: the budget carries into the next day, because a tank does not empty overnight', () => {
const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0)
expect(first.carryKm).toBe(400)
const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm)
expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }])
})
it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => {
const out = deriveDriveWarnings(
[undefined, leg(60, 500)],
[false, false, false],
{ ...noLimits, rangeKm: 100 },
0,
)
expect(out.warnings).toEqual([])
expect(out.carryKm).toBeNull()
})
it('FE-ROADTRIP-MODEL-068: filling up at the last stop of the day still counts', () => {
const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0)
expect(out.carryKm).toBe(0)
})
})
describe('sectionAnchors', () => {
it('FE-ROADTRIP-MODEL-080: every stop, and the middle of every leg between them', () => {
// The leg midpoints are the point of the feature: a break on a five-hour drive is
// planned in the middle of the drive, not at either end of it.
expect(sectionAnchors([0, 100, 250])).toEqual([
{ kind: 'stop', index: 0, alongKm: 0 },
{ kind: 'leg', index: 0, alongKm: 50 },
{ kind: 'stop', index: 1, alongKm: 100 },
{ kind: 'leg', index: 1, alongKm: 175 },
{ kind: 'stop', index: 2, alongKm: 250 },
])
})
it('FE-ROADTRIP-MODEL-081: a single stop has no leg to sit in the middle of', () => {
expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }])
expect(sectionAnchors([])).toEqual([])
})
})
describe('deriveDriveWarnings — what counts as driving', () => {
const drive = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
const onFoot = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'walking' })
it('FE-ROADTRIP-MODEL-085: a walk to a viewpoint is not over the longest drive allowed', () => {
// A day may legitimately mix modes — splitIntoRuns exists for exactly that —
// and a two hour hike reported as a leg finding reads as a fault in a plan
// that has none.
const out = deriveDriveWarnings(
[drive(60, 80), onFoot(120, 6)],
[false, false, false],
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
0,
)
expect(out.warnings).toEqual([])
})
it('FE-ROADTRIP-MODEL-086: walked kilometres are not on the tank', () => {
// Nor does walking fill it: the budget is left exactly as the drive left it.
const out = deriveDriveWarnings(
[drive(60, 80), onFoot(120, 60), drive(30, 30)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
0,
)
// 80 then 110 — the 60 walked kilometres never entered the budget.
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }])
expect(out.carryKm).toBe(110)
})
it('FE-ROADTRIP-MODEL-087: a walk does not count toward the driving time of the day', () => {
const out = deriveDriveWarnings(
[drive(60, 80), onFoot(180, 9)],
[false, false, false],
{ legMinutes: null, dayMinutes: 90, rangeKm: null },
0,
)
expect(out.day).toBeNull()
})
it('FE-ROADTRIP-MODEL-088: a leg with no mode is still a drive', () => {
// Every leg was a drive before the field existed, so an absent mode counts.
// Reading it the other way round would silently switch the warnings off.
const out = deriveDriveWarnings(
[{ duration: 120 * 60, distance: 200 * 1000 }],
[false, false],
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
0,
)
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
})
})
@@ -1,641 +0,0 @@
/**
* Pure road-trip arithmetic — no React, no network, no store. Everything here is a
* function of the stops, the routed legs and the day's rules, so it can be unit tested
* on its own and reused by the map, the rail and (later) the MCP tool.
*/
/**
* The stop kinds that interrupt a drive rather than end it.
*
* Every kind the corridor search can find, and the list is meant to stay that way: what
* you pick off "along the route" is by definition something you come across on the way,
* so it belongs inside the leg it was found on rather than among the places the trip is
* for. They are drawn on the dashed line with their own icon, carry no number, and are
* left out of every stop count — four places with a charger, a rest stop and a bakery
* between them is a four-stop day.
*
* Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
* enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
* a destination on one screen and a pause on the next.
*/
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
/**
* What each kind of pause is coloured, on the rail, in the corridor list and on the map.
*
* Read as road signage rather than as a palette: a pump is petrol orange, a charger is
* the yellow of electricity, a rest area takes the blue every parking sign in Europe is
* printed in, a campsite is green. The last two are the colours the general place search
* already gives food and sights, so a restaurant found along the route looks like the
* restaurants found anywhere else.
*
* Deliberately literal and shared rather than tokenised: these mean the same thing in
* both themes the way a motorway sign does, and one definition is what stops the rail,
* the search result and the map pin from drifting into three different reds.
*/
export const SERVICE_COLORS: Record<string, string> = {
fuel: '#E8590C',
charging: '#CA8A04',
rest_area: '#3B82F6',
campsite: '#16A34A',
restaurant: '#EF4444',
sights: '#EC4899',
}
/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
export function serviceColor(stopType: string | null | undefined): string {
return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
}
/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
export function isServiceStopType(stopType: string | null | undefined): boolean {
return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
}
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
export function formatDurationShort(seconds: number): string {
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
const h = Math.floor(safe / 3600)
const m = Math.round((safe % 3600) / 60)
// 59.6 min must not print as "60 min"; carry it.
if (m === 60) return `${h + 1} h`
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`
return `${m} min`
}
/** "09:45" → 585. Anything that is not a wall-clock time returns null. */
export function parseClock(value: string | null | undefined): number | null {
if (!value) return null
const m = /^(\d{1,2}):(\d{2})/.exec(value.trim())
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (h > 23 || min > 59) return null
return h * 60 + min
}
/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */
export function formatClock(minutes: number): string {
const day = 24 * 60
const wrapped = ((minutes % day) + day) % day
const h = Math.floor(wrapped / 60)
const m = Math.round(wrapped % 60)
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
export interface ScheduleStop {
/** Wall-clock time the user pinned on this stop, if any. */
anchor: string | null
/** Planned time spent here, in minutes. */
dwellMinutes: number | null
}
export interface ScheduleWarning {
/**
* Index of the stop the finding belongs to.
*
* For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves.
* Both findings are a running total that only becomes true on arrival: "125 km since
* the last fill-up" is what the tank reads when you get there, and the figure has to
* agree with the one anybody adds up off the drive bands above it. Anchored to the
* departure it read as the distance of the leg leaving that stop, which is a different
* number entirely and the one that made the badge look wrong.
*/
index: number
code: 'late' | 'overnight' | 'leg' | 'range'
/** Minutes the computed arrival misses the anchor by (code 'late'). */
minutes?: number
/** Minutes this leg runs over the longest single drive allowed (code 'leg'). */
overMinutes?: number
/** Kilometres driven since the last fill-up when the range ran out (code 'range'). */
sinceKm?: number
}
/**
* The two kinds of stop that put fuel or charge back.
*
* Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and
* TREK knows nothing about whether the restaurant has a charger in its car park. Reusing
* the service list here would quietly reset the range budget at every rest area and hide
* the one finding this feature exists to produce.
*/
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
/** Whether stopping here starts the range budget over. */
export function refuelsRange(stopType: string | null | undefined): boolean {
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
}
/**
* The three numbers a traveller can set. null means no limit, which is an explicit off
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
*/
export interface DriveLimits {
/** Longest single drive between two stops, in minutes. */
legMinutes: number | null
/** Longest total driving in one day, in minutes. */
dayMinutes: number | null
/** How far one tank or charge goes, in kilometres. */
rangeKm: number | null
}
/**
* A finding about the day as a whole.
*
* Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be
* read by every consumer that filters findings by stop, and one of them would eventually
* draw the day's finding on the first stop.
*/
export interface DayWarning {
code: 'dayDriving'
/** Driving time of the day in minutes, the same number the badge beside it shows. */
minutes: number
limitMinutes: number
}
export interface ScheduleEntry {
/** Computed arrival, or the anchor where the user pinned one. */
arrival: string | null
departure: string | null
/** True when the time was pinned by the user rather than derived. */
anchored: boolean
/** Days past the first stop, so a chain running past midnight stays readable. */
dayOffset: number
}
export interface Schedule {
entries: ScheduleEntry[]
warnings: ScheduleWarning[]
}
/**
* Walks the chain forward: arrival = previous departure + driving time, departure =
* arrival + time spent at the stop. A stop the user pinned a time on does not move —
* it restarts the cascade from its own time, which is what makes a ferry, a museum
* ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way).
*
* `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is
* `undefined` and breaks the chain rather than inventing a duration.
*/
/**
* Where a stop's clock actually starts.
*
* A pinned time wins over whatever the drive before it worked out and restarts the
* cascade from there. When it sits before the arrival the drive implies, the plan does
* not fit: the caller is told by how much rather than the pinned time being moved.
*/
const DAY_MINUTES = 24 * 60
function resolveArrival(
anchor: number | null,
cursor: number | null,
dayOffset: number,
): { arrival: number | null; lateBy: number | null } {
if (anchor === null) return { arrival: cursor, lateBy: null }
// No drive has been timed yet, so there is nothing to be late against — but the
// day carried so far still applies, or a pinned stop after an untimed leg
// prints under a day-1 stop as though it happened first.
if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }
// The occurrence of that wall-clock time nearest the cursor, not the one on the
// day the cascade has reached so far. `dayOffset` only advances once a computed
// arrival crosses midnight, and it is read before this stop's arrival is known —
// so when the stop that crosses midnight is the pinned one, its anchor was
// placed a whole day early and the plan was reported as 24 hours late while
// being exactly on time. Rounding also keeps the ordinary case: 08:00, a three
// hour leg, a 09:00 pin still picks the same day and is two hours late.
const k = Math.round((cursor - anchor) / DAY_MINUTES)
const anchorAt = anchor + k * DAY_MINUTES
return {
arrival: anchorAt,
lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null,
}
}
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
const entries: ScheduleEntry[] = []
const warnings: ScheduleWarning[] = []
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
let cursor: number | null = null
let dayOffset = 0
for (let i = 0; i < stops.length; i++) {
const stop = stops[i]
const anchor = parseClock(stop.anchor)
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
if (arrival === null) {
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
continue
}
const offset = Math.floor(arrival / (24 * 60))
if (offset > dayOffset) {
dayOffset = offset
warnings.push({ index: i, code: 'overnight' })
}
const dwell = stop.dwellMinutes ?? 0
const departure = arrival + dwell
entries.push({
arrival: formatClock(arrival),
departure: formatClock(departure),
anchored: anchor !== null,
dayOffset: offset,
})
const leg = legSeconds[i]
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
}
return { entries, warnings }
}
/**
* Splits a day's stops into the stretches that can be asked for in one routing request:
* consecutive stops whose leg shares a travel mode.
*
* The router returns a leg for every consecutive pair of the waypoints it is handed, so
* a day travelled one way is a single request rather than one per leg — the difference
* between a road trip appearing at once and crawling in over ten seconds. A day that
* mixes walking and driving splits at the change, because one request carries one mode.
*/
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
const runs: { stops: T[]; mode: string }[] = []
let current: T[] = []
let currentMode: string | null = null
for (let i = 0; i < stops.length - 1; i++) {
const mode = modeOfLeg(stops[i], stops[i + 1])
if (currentMode === null) {
current = [stops[i], stops[i + 1]]
currentMode = mode
} else if (mode === currentMode) {
current.push(stops[i + 1])
} else {
runs.push({ stops: current, mode: currentMode })
current = [stops[i], stops[i + 1]]
currentMode = mode
}
}
if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode })
return runs
}
/** Total driving seconds of the legs that actually routed. */
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
}
/**
* Where a place found along the drive belongs in the day's chain.
*
* Both numbers are distances travelled along the same routed line, so the comparison is
* "which stops has the car already passed when it reaches this one". A petrol station
* 120 km into the day goes after every stop nearer than that and before the rest,
* which is what makes adding one a single click rather than a drag afterwards.
*
* Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
* slightly before the first stop or past the last, and neither "before where you set off"
* nor "after where you arrive" is a place a fuel stop can go.
*/
/**
* Which leg a point on the drive belongs to, given where each leg ends.
*
* Measured against the legs' own lengths rather than by projecting the stops as well: the
* router's leg distances are exact by construction, while two stops close together
* project onto each other's stretch and would put a charging halt on the wrong side of a
* town. A point past the end lands in the last leg rather than nowhere, because that is
* a rounding edge and not a missing leg.
*/
/**
* The findings about the driving itself: too long at the wheel, too long in one day, and
* the tank running out before anywhere to fill it.
*
* Reads the legs and nothing else. The clock cascade is not available to lean on: with no
* stop pinned to a time it produces no times at all, for any stop, so a limit expressed as
* an hour of the day would simply never fire. All three limits are therefore durations and
* kilometres, which exist as soon as a leg has routed.
*
* The range budget resets at a fuel or charging stop and NOWHERE ELSE. It carries across
* midnight, because a tank does not empty overnight, and it keeps counting past a finding,
* because a warning is not a fill-up. Zeroing it there made the second figure wrong and
* every one after it: a day reading 245 km at one stop and 121 at the next claims the 121
* is a fresh tank, when it is 366 on the same one. A run of warnings down a stretch with
* no fuel on it is not noise; it is the answer, and it stops the moment a fuel or
* charging stop is put in.
*
* An unrouted leg gives the budget up entirely rather than guessing, the same way the
* schedule abandons its cursor: a distance we do not have cannot be added to one we do.
*/
export function deriveDriveWarnings(
/**
* `mode` decides whether a leg counts at all. A day may legitimately mix
* modes — splitIntoRuns exists for exactly that — and a two hour walk to a
* viewpoint is not two hours of driving, nor are its kilometres on the tank.
* Counting them reported a hike as "over the longest drive allowed" and let a
* ferry crossing trip a range warning. Anything that is not a driving profile
* is skipped; an absent mode counts, because every leg was a drive before the
* field existed.
*/
legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[],
/** Whether the stop at each index refuels. One entry longer than `legs`. */
refuelsAt: boolean[],
limits: DriveLimits,
/** Kilometres already on the tank when the day starts; null when that is unknown. */
carryKm: number | null,
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
const warnings: ScheduleWarning[] = []
let budget = carryKm
let totalSeconds = 0
for (let i = 0; i < legs.length; i++) {
if (refuelsAt[i]) budget = 0
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
// Not a drive: neither its minutes nor its kilometres belong in findings
// about the driving. The budget is left exactly as it was — walking to a
// viewpoint and back does not use fuel, and it does not fill the tank
// either.
if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue
const seconds = leg?.duration
if (typeof seconds === 'number') {
totalSeconds += seconds
if (limits.legMinutes && seconds / 60 > limits.legMinutes) {
warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
}
}
const metres = leg?.distance
if (typeof metres !== 'number') {
budget = null
} else if (budget !== null) {
budget += metres / 1000
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
// the plan working, not a problem, and a warning there would sit on the one stop
// that answers it. No reset either — a warning is not a fill-up, and the figure has
// to keep counting until a fuel or charging stop actually puts one in.
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
}
}
}
// The last stop of the day counts too: filling up on arrival is what makes the next
// morning start with a full tank.
if (refuelsAt[legs.length]) budget = 0
const minutes = Math.round(totalSeconds / 60)
const day = limits.dayMinutes && minutes > limits.dayMinutes
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
: null
return { warnings, day, carryKm: budget }
}
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
if (!legEndMeters.length) return -1
for (let i = 0; i < legEndMeters.length; i++) {
if (alongMeters < legEndMeters[i]) return i
}
return legEndMeters.length - 1
}
/**
* The places on a day somebody would plan a break around.
*
* Every stop, and the middle of every leg between two of them. The leg midpoints are the
* point of the whole thing: a break on a five-hour drive is planned in the middle of the
* drive, not at either end of it, and offering only the stops would mean asking "what is
* near Berlin" when the question is "what is near the halfway mark".
*/
export interface SectionAnchor {
kind: 'stop' | 'leg'
/** Index of the stop, or of the stop the leg leaves. */
index: number
alongKm: number
}
export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] {
const out: SectionAnchor[] = []
for (let i = 0; i < stopsAlongKm.length; i++) {
out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i] })
if (i + 1 < stopsAlongKm.length) {
out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i] + stopsAlongKm[i + 1]) / 2 })
}
}
return out
}
export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
if (stopsAlongKm.length < 2) return stopsAlongKm.length
let i = 0
while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
}
/* ── Via re-anchoring ────────────────────────────────────────────────────────
*
* A via records which stop it follows as a POSITION in the day's stop list, and that
* position is resolved fresh every time the day is routed. So the moment the list
* changes shape — a stop inserted, removed, reordered, pushed to another day — every
* anchor at or past the change means a different leg than it did when it was written.
* The visible symptom is the route quietly snapping back to the road the user had
* steered it away from, with the via handle still sitting on the map where they left it.
*
* Nothing about that is repairable after the fact: once the stops have moved there is no
* record of which leg a via was drawn for. So the correction has to be computed against
* the list as it stands and applied with the same change.
*
* These are pure index arithmetic on purpose. The one judgement that needs geometry —
* which half of a split leg a via belongs to — is asked of the caller, because only the
* caller has the routed line to measure against.
*/
/**
* A via, reduced to what re-anchoring needs to know about it.
*
* The coordinates are part of it because the one decision that is not pure arithmetic —
* which half of a split leg a via belongs to — is answered by measuring where it sits on
* the road, and the caller is handed the whole via to measure.
*/
export interface AnchoredVia {
id: number
after_order_index: number
lat: number
lng: number
}
/** Where one via ends up. */
export interface ReanchoredVia {
id: number
after_order_index: number
}
/**
* The corrected anchoring, and the vias that no longer have a leg at all.
*
* `vias` holds only what actually moved: sending the unchanged ones would be a larger
* write for no reason, and it keeps the diff readable when something goes wrong.
*/
export interface Reanchoring {
vias: ReanchoredVia[]
remove: number[]
}
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
/** Only what moved, so an operation that changes nothing costs no request. */
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
const moved: ReanchoredVia[] = []
const remove: number[] = []
for (const via of vias) {
const next = at(via.after_order_index)
if (next === null) remove.push(via.id)
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove }
}
/**
* A stop was inserted at `position`, so everything from there on sits one place later.
*
* The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
* before the new stop stays on the first half, one after it moves to the second. That is
* the whole reason a petrol station added in the middle of a reshaped drive used to undo
* the reshaping — the via went to the first half by accident of arithmetic, the second
* half got none, and the router took its own road again for the rest of the leg.
*
* `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
* one: inserting at the very front or the very end splits nothing.
*/
export function reanchorAfterInsert(
vias: AnchoredVia[],
position: number,
liesBeforeInsert: (via: AnchoredVia) => boolean,
): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
const split = position - 1
const moved: ReanchoredVia[] = []
for (const via of vias) {
const i = via.after_order_index
let next = i
if (i === split) next = liesBeforeInsert(via) ? i : i + 1
else if (i >= position) next = i + 1
if (next !== i) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove: [] }
}
/**
* A stop was taken out at `position`, so the legs either side of it become one.
*
* A via on either half keeps bending the same drive, so both end up on the merged leg —
* dropping them would throw away a decision the user made about a road that still exists.
* The exception is the ends: remove the first stop and the leg leaving it is gone, remove
* the last and the leg into it is gone, and a via pinned to one of those has nowhere left
* to sit. Those are reported for deletion rather than left pointing past the day.
*
* `stopCount` is the number of stops BEFORE the removal.
*/
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
// Two stops become one: the day has no drive left, so no via has a leg.
if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
return collect(vias, i => {
if (position === 0) return i === 0 ? null : i - 1
if (position === stopCount - 1) return i === position - 1 ? null : i
if (i === position) return position - 1
return i > position ? i - 1 : i
})
}
/**
* A stop moved from `from` to `to` inside its own day.
*
* Composed from the two operations it actually is — taken out, put back — because that
* is the only reading under which the answer stays consistent with the other two. No
* geometry is asked for: a reorder changes which stops are neighbours, so the roads
* themselves are about to be different and there is nothing meaningful to measure a via
* against. Vias keep the earlier half of a split leg, which is the reading that leaves
* them where they were relative to the stop they were drawn after.
*
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
* it looks AFTER the stop has been taken out.
*/
/**
* Re-pin a day's vias after its stops were reordered wholesale.
*
* The single-move helpers above take a from/to pair, which is what the rail
* hands them. The plan-mode drag hands a whole new ordering instead, and any
* permutation is possible — so this maps by the stop each anchor refers to
* rather than by arithmetic on the index. A via anchored after a stop stays
* anchored after that same stop, wherever it ended up.
*
* Both lists are in the rail's index space, i.e. only stops that can be routed.
* A via whose stop is gone, or which lands on the last stop and so has no leg
* left to bend, is reported for removal.
*/
export function reanchorByStopOrder(
vias: AnchoredVia[],
previousIds: number[],
nextIds: number[],
): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
const moved: ReanchoredVia[] = []
const remove: number[] = []
for (const via of vias) {
const stopId = previousIds[via.after_order_index]
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId)
// Gone, or now the last stop of the day: either way there is no leg for it.
if (next === -1 || next >= nextIds.length - 1) {
remove.push(via.id)
continue
}
if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove }
}
export function reanchorAfterReorder(
vias: AnchoredVia[],
from: number,
to: number,
stopCount: number,
): Reanchoring {
if (!vias.length || from === to) return EMPTY_REANCHORING
// A reorder is modelled as remove-then-insert, and the removal half refuses to
// treat a two-stop day as still having a drive: below three stops it reports
// every via for deletion, which is right when a stop really goes away and
// catastrophic here. Nothing is removed by a reorder — swapping the only two
// stops leaves leg 0 exactly where it was, merely driven the other way round —
// so the anchors are already correct and there is nothing to re-pin.
//
// Two stops is the shape "follow this track" produces: it lays up to nine vias
// on the single leg between one pair, and one drag in the rail deleted all of
// them, with no prompt and no undo.
if (stopCount <= 2) return EMPTY_REANCHORING
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
const dropped = new Set(afterRemove.remove)
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
// The list the insert acts on is the one the removal left behind, so each via is
// carried through at whatever index it holds by then.
const shifted: AnchoredVia[] = vias
.filter(v => !dropped.has(v.id))
.map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
const afterInsert = reanchorAfterInsert(shifted, to, () => true)
const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
const result: ReanchoredVia[] = []
for (const via of vias) {
if (dropped.has(via.id)) continue
const mid = movedTo.get(via.id) ?? via.after_order_index
const end = finalIndex.get(via.id) ?? mid
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
}
return { vias: result, remove: afterRemove.remove }
}
/** Whether a re-anchoring has anything to write at all. */
export function isEmptyReanchoring(r: Reanchoring): boolean {
return r.vias.length === 0 && r.remove.length === 0
}
@@ -1,75 +0,0 @@
import { describe, it, expect } from 'vitest'
import { serviceMarkerHtml, serviceMarkerOuter, SERVICE_MARKER_SIZE, SERVICE_MARKER_SIZE_SELECTED } from './serviceMarker'
import { serviceColor } from './roadtripModel'
/**
* FE-SVCMARK-001..007 — the disc a service stop gets on the map.
*
* Both renderers need the same string: one hands it to a Leaflet divIcon, the
* other to an element it attaches itself. What is worth pinning is that an
* ordinary place gets nothing at all — a petrol station is something the drive
* passes through, and giving it a place-sized marker made a day look like it had
* twice the stops it has — and that the anchor arithmetic includes the border,
* or the pin sits a couple of pixels off the coordinate it marks.
*/
describe('serviceMarkerHtml', () => {
it('FE-SVCMARK-001: an ordinary place gets no service marker', () => {
// Null is the signal to fall back to the place marker, so this is what keeps
// a destination from being drawn as a fuel stop.
expect(serviceMarkerHtml(null, false)).toBeNull()
expect(serviceMarkerHtml(undefined, false)).toBeNull()
expect(serviceMarkerHtml('', false)).toBeNull()
})
it('FE-SVCMARK-002: each kind of stop gets a disc in its own colour', () => {
// All six, not only the four the corridor picker offers: a meal and a
// viewpoint are pauses on the way too, and the map draws them the same way.
for (const kind of ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']) {
const html = serviceMarkerHtml(kind, false)
expect(html, kind).toBeTruthy()
expect(html, kind).toContain(serviceColor(kind))
expect(html, kind).toContain('border-radius:50%')
}
})
it('FE-SVCMARK-003: an unknown service kind is still not drawn as one', () => {
// isServiceStopType is the gate; a value the picker never offers must not
// reach the renderer and produce a disc in an undefined colour.
expect(serviceMarkerHtml('banana', false)).toBeNull()
})
it('FE-SVCMARK-004: selecting one grows it, the way a place marker confirms a click', () => {
const plain = serviceMarkerHtml('fuel', false)!
const selected = serviceMarkerHtml('fuel', true)!
expect(plain).toContain(`width:${SERVICE_MARKER_SIZE}px`)
expect(selected).toContain(`width:${SERVICE_MARKER_SIZE_SELECTED}px`)
expect(SERVICE_MARKER_SIZE_SELECTED).toBeGreaterThan(SERVICE_MARKER_SIZE)
})
it('FE-SVCMARK-005: selected carries the heavier ring, unselected the plain shadow', () => {
expect(serviceMarkerHtml('fuel', true)!).toContain('0 0 0 3px')
expect(serviceMarkerHtml('fuel', false)!).not.toContain('0 0 0 3px')
})
it('FE-SVCMARK-006: the icon is drawn white and inline, never a logo or a photo', () => {
// A fuel stop used to come back from the place search carrying the
// operator's logo, so the map showed a brand roundel where every other
// road-trip surface showed a pump.
const html = serviceMarkerHtml('fuel', false)!
expect(html).toContain('<svg')
expect(html).toContain('#fff')
expect(html).not.toContain('<img')
expect(html).not.toContain('background-image')
})
})
describe('serviceMarkerOuter', () => {
it('FE-SVCMARK-007: the box an anchor centres on includes the border', () => {
// content-box plus a 2px border on each side. Measuring the inner size would
// leave every service pin two pixels off the coordinate it marks.
expect(serviceMarkerOuter(false)).toBe(SERVICE_MARKER_SIZE + 4)
expect(serviceMarkerOuter(true)).toBe(SERVICE_MARKER_SIZE_SELECTED + 4)
})
})
@@ -1,55 +0,0 @@
import { createElement } from 'react'
import { ParkingSquare } from 'lucide-react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { isServiceStopType, serviceColor } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
/**
* Smaller than a place marker on purpose.
*
* A petrol station is not a destination — it is something the drive passes through — and
* a marker the same size as the places the trip is for made a day look like it had twice
* as many stops as it has. Selected grows a little, the same way a place marker does, so
* clicking one still confirms itself.
*/
export const SERVICE_MARKER_SIZE = 24
export const SERVICE_MARKER_SIZE_SELECTED = 30
const BORDER = 2
/** The full box a service marker occupies, border included — what an anchor centres on. */
export function serviceMarkerOuter(selected: boolean): number {
return (selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE) + BORDER * 2
}
/**
* The marker for a stop that interrupts the drive, or null for an ordinary place.
*
* One flat disc in the kind's own colour with a white icon — never the place's photo or
* its brand logo. A fuel stop used to come back from the place search carrying the
* operator's logo, so the map showed an Esso roundel where every other road-trip surface
* showed a pump: unreadable at marker size, different for every brand, and nothing at all
* for the ones with no logo on file.
*
* Built as markup rather than as a component because both renderers need a string: one
* feeds it to a Leaflet `divIcon`, the other to an element it attaches itself.
*/
export function serviceMarkerHtml(stopType: string | null | undefined, selected: boolean): string | null {
if (!isServiceStopType(stopType)) return null
const size = selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE
const Icon = STOP_KIND_BY_KEY[stopType ?? '']?.Icon ?? ParkingSquare
const svg = renderIconMarkup(createElement(Icon, {
size: Math.round(size * 0.56),
color: '#fff',
strokeWidth: 2.4,
}))
const ring = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
: '0 2px 6px rgba(0,0,0,0.25)'
return `<div style="
width:${size}px;height:${size}px;border-radius:50%;
background:${serviceColor(stopType)};
border:${BORDER}px solid #fff;box-shadow:${ring};
display:flex;align-items:center;justify-content:center;
box-sizing:content-box;cursor:pointer;
">${svg}</div>`
}
@@ -1,83 +0,0 @@
import { describe, it, expect } from 'vitest'
import { roadtripStopTypeSchema } from '@trek/shared'
import { SERVICE_STOP_TYPES, SERVICE_COLORS } from './roadtripModel'
import {
STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY, CORRIDOR_CATEGORY_KEYS,
SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES, isOvernightCategory,
} from './stopKinds'
/**
* The table exists to stop nine lists from drifting apart, and these are the assertions
* that make that true rather than intended. Each one pins the table against a list that
* cannot import it: the Zod enum lives in `@trek/shared` and must not depend on the
* client, and `SERVICE_STOP_TYPES` lives in the React-free model.
*/
describe('stop kinds', () => {
it('FE-STOPKIND-001: covers exactly the kinds the shared enum allows', () => {
const fromSchema = [...roadtripStopTypeSchema.options].sort()
const fromTable = STOP_KINDS.map(k => k.key).sort()
expect(fromTable).toEqual(fromSchema)
})
it('FE-STOPKIND-002: agrees with the model about which kinds interrupt a drive', () => {
expect([...SERVICE_KIND_KEYS].sort()).toEqual([...SERVICE_STOP_TYPES].sort())
})
it('FE-STOPKIND-003: every kind carries the colour the model gives it', () => {
for (const kind of STOP_KINDS) expect(kind.color).toBe(SERVICE_COLORS[kind.key])
})
it('FE-STOPKIND-004: a refuelling kind is a kind, so a range budget cannot reset on a typo', () => {
for (const key of REFUELLING_STOP_TYPES) expect(STOP_KIND_BY_KEY[key]).toBeDefined()
})
it('FE-STOPKIND-005: the corridor offers every category in a stable order', () => {
// Sleeping sits next to sleeping: hotel follows campsite rather than landing wherever
// the stop-kind table happens to end.
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights'])
})
it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => {
// The whole reason the two lists are separate. Sleeping somewhere ends the day rather
// than interrupting the drive, so a hotel keeps its number in the chain and never
// becomes a stop_type.
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull()
expect(STOP_KIND_BY_KEY.hotel).toBeUndefined()
expect(SERVICE_KIND_KEYS).not.toContain('hotel')
})
it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => {
expect(isOvernightCategory('hotel')).toBe(true)
expect(isOvernightCategory('campsite')).toBe(true)
expect(isOvernightCategory('fuel')).toBe(false)
expect(isOvernightCategory(null)).toBe(false)
})
it('FE-STOPKIND-010: every category that is a stop kind names the kind it creates', () => {
for (const category of CORRIDOR_CATEGORIES) {
if (category.stopKind === null) continue
expect(category.stopKind).toBe(category.key)
expect(STOP_KIND_BY_KEY[category.key]).toBeDefined()
}
})
it('FE-STOPKIND-006: two kinds are named under a key of their own, and that is on purpose', () => {
// Spelled out rather than derived: the corridor calls a rest area rest_area and names
// it under rest, a restaurant restaurant and names it under food. A derived label key
// would silently ask for two translations that do not exist.
expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest')
expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food')
for (const kind of STOP_KINDS) {
if (kind.key !== 'rest_area' && kind.key !== 'restaurant') {
expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`)
}
}
})
it('FE-STOPKIND-007: every kind offers a dwell the popup can pre-fill', () => {
for (const kind of STOP_KINDS) {
expect(kind.defaultMinutes).toBeGreaterThan(0)
expect(Number.isInteger(kind.defaultMinutes)).toBe(true)
}
})
})
-120
View File
@@ -1,120 +0,0 @@
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, BedDouble, type LucideIcon } from 'lucide-react'
import { SERVICE_COLORS, SERVICE_STOP_TYPES } from './roadtripModel'
import type { RoadtripStopType } from '@trek/shared'
/**
* One row per kind of stop a drive can have, and the only place a new one is added.
*
* The six kinds used to be written out in nine separate lists: the enum in `@trek/shared`,
* `SERVICE_STOP_TYPES` and `SERVICE_COLORS` here in the model, the corridor's category
* keys, the map's `ROADTRIP_POI_CATEGORIES`, the popup's own `STOP_KINDS`, two lookups in
* the rail, one in the map markers and one in the corridor panel. Every one of those
* carried a comment telling the next person to keep the others in step, and it still came
* apart: a rest area was a parking sign in the popup, on the map pill and in the panel,
* and a coffee cup on the rail disc and the map marker, for the same stop. That is fixed
* here in favour of the parking sign, because the colour is already documented as "the
* blue every parking sign in Europe is printed in".
*
* The table lives beside the model rather than in it because an icon is a React component
* and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its
* own. The colours and the service list stay there and are read from here.
*
* `isService` and `isCorridorCategory` are not the same question, and a coming lodging
* kind is why they are separate: the corridor can find a hotel, but sleeping somewhere
* ends the day rather than interrupting the drive, so it would be a category without
* being a service stop.
*/
export interface StopKind {
key: RoadtripStopType
/** Two of the six do not match their key: `rest_area` is named under `rest`, `restaurant` under `food`. */
labelKey: string
Icon: LucideIcon
color: string
/** What the popup offers before anyone changes it — how long this kind usually takes. */
defaultMinutes: number
/** Interrupts the drive: drawn on the dashed line, no number, left out of every stop count. */
isService: boolean
/** Offered as a category in the corridor search. */
isCorridorCategory: boolean
}
export const STOP_KINDS: StopKind[] = [
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true },
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true },
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true },
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, defaultMinutes: 60, isService: true, isCorridorCategory: true },
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, defaultMinutes: 45, isService: true, isCorridorCategory: true },
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, defaultMinutes: 30, isService: true, isCorridorCategory: true },
]
/** By key, for the lookups the rail, the markers and the panel used to keep themselves. */
export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
STOP_KINDS.map(k => [k.key, k]),
)
/**
* A category the corridor search offers, which is not the same question as a stop kind.
*
* Every stop kind is a category, but not every category is a stop kind: somewhere to
* sleep is something the search can find, and staying there ends the day rather than
* interrupting the drive. A hotel therefore keeps its number in the chain and gets a row
* in `day_accommodations`; it never becomes a `stop_type`.
*/
export interface CorridorCategory {
key: string
labelKey: string
Icon: LucideIcon
color: string
/** The kind a hit of this category becomes, or null when it becomes something else. */
stopKind: RoadtripStopType | null
}
/** Somewhere to sleep. Blue rather than the campsite's green: both are a night, but one
* of them has a roof, and telling the two apart on a map matters more than grouping them.
* theme-lint-disable — the road-signage palette, shared with the general place search so
* a hotel found along a route looks like the hotels found anywhere else. */
export const HOTEL_COLOR = '#2563EB'
/**
* The categories, in the order the panel shows them.
*
* Written out rather than derived, because the order is a judgement: sleeping sits next
* to sleeping, so `hotel` follows `campsite` instead of landing wherever the stop-kind
* table happens to end.
*/
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights')
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights')
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
]
export const CORRIDOR_CATEGORY_BY_KEY: Record<string, CorridorCategory> = Object.fromEntries(
CORRIDOR_CATEGORIES.map(c => [c.key, c]),
)
/** The keys the corridor search offers, in the order the panel shows them. */
export const CORRIDOR_CATEGORY_KEYS = CORRIDOR_CATEGORIES.map(c => c.key)
/** Categories whose hit becomes a night rather than a pause. */
export function isOvernightCategory(category: string | null | undefined): boolean {
// Campsite is both: it is a service stop today and stays one, but a night can be
// booked there just as well, so the popup offers the choice rather than deciding.
return category === 'hotel' || category === 'campsite'
}
/**
* A guard against the drift this file exists to end.
*
* `SERVICE_STOP_TYPES` is the model's own list and stays there, because the arithmetic
* must not depend on anything that imports React. The two are checked against each other
* in `stopKinds.test.ts` rather than derived, so a kind added to one and forgotten in the
* other fails a test instead of quietly counting as a destination.
*/
export const SERVICE_KIND_KEYS = STOP_KINDS.filter(k => k.isService).map(k => k.key)
/** The kinds where a tank or a battery is filled, so a range budget starts over. */
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
export { SERVICE_STOP_TYPES }
@@ -1,39 +0,0 @@
/**
* The road trip's type scale, pinned to the day plan's.
*
* Both road trip panels sit beside the day plan and the tab switcher flips between
* them, so a road trip whose names ran a size larger than the day plan's read as a
* different app.
* The two anchors are the day plan's own: a day title is 14px and a place name 12.5px
* (`DayPlanSidebar`), and everything here is derived from those. The design handoff was
* drawn a fifth larger throughout — its proportions are kept, its absolute sizes are not.
*
* Written as `calc(px * var(--fs-scale-<tier>))` rather than as a tier utility because
* the tiers land on 24/18/14/12px and this rail needs the steps between them; the
* multiplier is what keeps the user's per-tier text-size setting reaching them. A value
* and its unit always share one tier, so scaling one tier cannot break a pair apart.
*/
export const FS = {
/** A total in the head. */
total: 'calc(20px * var(--fs-scale-title, 1))',
/** The unit hanging off one. */
totalUnit: 'calc(11.5px * var(--fs-scale-title, 1))',
/** A day's name — the day plan's own size. */
dayTitle: 'calc(14px * var(--fs-scale-body, 1))',
/** A stop's name — the day plan's own size. */
name: 'calc(12.5px * var(--fs-scale-body, 1))',
/** A clock reading, and a service stop's dwell. */
time: 'calc(11px * var(--fs-scale-caption, 1))',
/** A drive band, a date, a line of explanation. */
meta: 'calc(10px * var(--fs-scale-caption, 1))',
/** The number in a marker. */
marker: 'calc(10.5px * var(--fs-scale-caption, 1))',
/** A badge, a caption over a total. */
label: 'calc(9px * var(--fs-scale-caption, 1))',
/** The word "Stay" — the smallest thing in the rail. */
micro: 'calc(8px * var(--fs-scale-caption, 1))',
/** The name of a panel — "Along the route". The largest text either panel carries. */
panelTitle: 'calc(15px * var(--fs-scale-subtitle, 1))',
/** A pill, a segment, a line of progress: anything you press but do not read as prose. */
control: 'calc(10.5px * var(--fs-scale-body, 1))',
} as const
@@ -1,187 +0,0 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { LatLng } from './corridor'
const { pois } = vi.hoisted(() => ({ pois: vi.fn() }))
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
import { useCorridorPois } from './useCorridorPois'
/** A short drive west out of Hamburg — enough route to tile, few enough boxes to be quick. */
const LINE: LatLng[] = [
{ lat: 53.55, lng: 9.99 },
{ lat: 53.5, lng: 9.8 },
{ lat: 53.45, lng: 9.6 },
]
const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
osm_id: id,
name: id,
lat,
lng,
category,
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
})
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false })
beforeEach(() => {
pois.mockReset()
pois.mockResolvedValue(answer())
})
describe('useCorridorPois', () => {
it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => {
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(pois).toHaveBeenCalled()
// The whole point of the batching: the kinds ride along in one query per box.
for (const call of pois.mock.calls) expect(call[0]).toBe('fuel,charging,rest_area')
expect(result.current.progress.total).toBe(pois.mock.calls.length)
})
it('FE-ROADTRIP-CORRIDOR-002: keeps what is near the route and drops what is not', async () => {
// One beside the line, one far north of it.
pois.mockResolvedValue(answer(hit('near', 53.5, 9.81), hit('far', 54.6, 9.8)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 5))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.results.map(r => r.osm_id)).toEqual(['near'])
expect(result.current.results[0].offRouteKm).toBeLessThan(5)
expect(result.current.results[0].alongKm).toBeGreaterThan(0)
})
it('FE-ROADTRIP-CORRIDOR-003: orders hits by how far along the drive they are', async () => {
pois.mockResolvedValue(answer(
hit('late', 53.45, 9.61),
hit('early', 53.55, 9.98),
hit('middle', 53.5, 9.8),
))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 8))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.results.map(r => r.osm_id)).toEqual(['early', 'middle', 'late'])
})
it('FE-ROADTRIP-CORRIDOR-004: the same place found in two overlapping boxes is listed once', async () => {
pois.mockResolvedValue(answer(hit('shared', 53.5, 9.81)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.results.filter(r => r.osm_id === 'shared')).toHaveLength(1)
})
it('FE-ROADTRIP-CORRIDOR-005: a box that times out is asked again before it counts as unsearched', async () => {
let first = true
pois.mockImplementation(async () => {
if (first) { first = false; throw new Error('502') }
return answer(hit('found', 53.5, 9.81))
})
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.failedAreas).toBe(0)
expect(result.current.error).toBe(false)
expect(result.current.results.length).toBeGreaterThan(0)
})
it('FE-ROADTRIP-CORRIDOR-006: a box that fails twice is reported rather than silently skipped', async () => {
pois.mockRejectedValue(new Error('502'))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.failedAreas).toBeGreaterThan(0)
// Nothing answered at all, so this is an outage and not a partial result.
expect(result.current.error).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-007: a drive longer than the request budget says the tail went unsearched', async () => {
const long: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 53.5 - i * 0.25, lng: 9.9 + i * 0.35 }))
const { result } = renderHook(() => useCorridorPois(long, ['fuel'], 5))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.capped).toBe(true)
expect(result.current.progress.total).toBeLessThanOrEqual(16)
})
it('FE-ROADTRIP-CORRIDOR-008: changing what is wanted drops the answers to the old question', async () => {
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
const { result, rerender } = renderHook(
({ categories }: { categories: string[] }) => useCorridorPois(LINE, categories, 10),
{ initialProps: { categories: ['fuel'] } },
)
act(() => { result.current.search() })
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
rerender({ categories: ['campsite'] })
await waitFor(() => expect(result.current.results).toEqual([]))
expect(result.current.progress).toEqual({ done: 0, total: 0 })
})
it('FE-ROADTRIP-CORRIDOR-009: a route too short to search asks for nothing', async () => {
const { result } = renderHook(() => useCorridorPois([LINE[0]], ['fuel'], 10))
act(() => { result.current.search() })
await act(async () => {})
expect(pois).not.toHaveBeenCalled()
expect(result.current.loading).toBe(false)
})
it('FE-ROADTRIP-CORRIDOR-010: with nothing selected there is nothing to look for', async () => {
const { result } = renderHook(() => useCorridorPois(LINE, [], 10))
act(() => { result.current.search() })
await act(async () => {})
expect(pois).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-CORRIDOR-011: clearing stops the run and empties the list', async () => {
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
act(() => { result.current.clear() })
expect(result.current.results).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.failedAreas).toBe(0)
})
it('FE-ROADTRIP-CORRIDOR-012: leaving the view aborts the boxes still in flight', async () => {
let seen: AbortSignal | undefined
pois.mockImplementation((_c: string, _b: unknown, _l: string, signal: AbortSignal) => {
seen = signal
return new Promise(() => {})
})
const { result, unmount } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(seen).toBeDefined())
unmount()
expect(seen!.aborted).toBe(true)
})
})
@@ -1,197 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
/** A POI that is actually on the way, with the two numbers that make it one. */
export interface CorridorPoi extends Poi {
offRouteKm: number
alongKm: number
}
export interface CorridorSearch {
results: CorridorPoi[]
/** Areas answered so far, out of how many the route was cut into. */
progress: { done: number; total: number }
loading: boolean
/** True when the route was longer than the request budget and the tail was left out. */
capped: boolean
/** Boxes the place search could not answer — a partial result is still shown. */
failedAreas: number
/**
* Boxes where the server hit its own per-request ceiling and dropped the rest.
* Distinct from `capped`, which is about stretches never asked for at all: here the
* stretch was searched and the answer came back short. Without saying so, filtering
* the results cannot tell "nothing of that name on the way" from "cut off before it".
*/
truncatedAreas: number
error: boolean
/** The thinned line every `alongKm` above is measured along. */
spine: LatLng[]
search: () => void
clear: () => void
}
/**
* How many bounding boxes one search may ask Overpass for. A German-to-Italian drive
* would otherwise be dozens of requests against a shared public mirror; past this the
* search stops and says so rather than quietly covering half the route.
*/
const MAX_TILES = 16
/** Gap after a request before the same worker starts the next — the mirrors are shared. */
const REQUEST_SPACING_MS = 250
/** How many boxes are in flight at once. */
const CONCURRENT_REQUESTS = 5
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal.aborted) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal.removeEventListener('abort', done)
resolve()
}
signal.addEventListener('abort', done, { once: true })
})
/**
* "What is on the way" for a drawn route.
*
* Deliberately manual: the search runs when the user asks for it, never on pan or on
* every route change, because each run is several Overpass requests and the mirrors are
* shared. Results stay until the categories or the route change.
*/
export function useCorridorPois(line: LatLng[], categories: string[], widthKm: number): CorridorSearch {
const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
const [loading, setLoading] = useState(false)
const [capped, setCapped] = useState(false)
const [failedAreas, setFailedAreas] = useState(0)
const [truncatedAreas, setTruncatedAreas] = useState(0)
const [error, setError] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const runIdRef = useRef(0)
/**
* The routed line, thinned. A corridor built from every vertex would make hundreds of
* overlapping boxes out of what a handful describe just as well.
*
* Shared rather than local to the search, because every `alongKm` in the results is a
* distance along THIS line. Anything comparing a hit against the day's own stops has to
* project them onto the same one, or the two sets of numbers do not mean the same thing.
*/
const spine = useMemo(() => simplifyLine(line, Math.max(1, widthKm / 3)), [line, widthKm])
const clear = useCallback(() => {
abortRef.current?.abort()
setResults([])
setProgress({ done: 0, total: 0 })
setLoading(false)
setCapped(false)
setFailedAreas(0)
setTruncatedAreas(0)
setError(false)
}, [])
// A changed route or category set makes the old answers wrong, not stale — drop them.
const routeKey = line.length ? `${line.length}:${line[0].lat.toFixed(3)},${line[0].lng.toFixed(3)}` : ''
const catKey = categories.join(',')
useEffect(() => { clear() }, [routeKey, catKey, widthKm, clear])
const search = useCallback(() => {
abortRef.current?.abort()
if (spine.length < 2 || categories.length === 0) return
const controller = new AbortController()
abortRef.current = controller
const runId = ++runIdRef.current
const allTiles = corridorTiles(spine, widthKm)
const tiles = allTiles.slice(0, MAX_TILES)
setCapped(allTiles.length > tiles.length)
setFailedAreas(0)
setTruncatedAreas(0)
setError(false)
setResults([])
setLoading(true)
setProgress({ done: 0, total: tiles.length })
// All the wanted kinds in one query per box. Asking per kind was the same answer for
// four times the requests, and every extra request is another chance for a shared
// mirror to time out — which is what made a search both slow and patchy.
const wanted = categories.join(',')
const jobs = [...tiles]
void (async () => {
const seen = new Map<string, CorridorPoi>()
let failures = 0
let shortened = 0
let done = 0
let next = 0
// Several at a time. Following the real road rather than the line between stops
// means more boxes to cover the same drive, and asking for them one after another
// made a search take the better part of a minute. The server races several
// Overpass mirrors per request and caches what comes back, so the boxes overlap
// cheaply; the small gap afterwards keeps one worker from queueing straight up.
const collect = async (tile: Bbox): Promise<boolean> => {
try {
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
// The server answers with at most a few hundred per box and says so. A hit
// dropped there is invisible here, so the count has to reach the surface.
if (data.truncated) setTruncatedAreas(++shortened)
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm) continue
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
}
return true
} catch {
return false
}
}
const publish = (): void => {
setProgress({ done, total: jobs.length })
setResults([...seen.values()].sort((a, b) => a.alongKm - b.alongKm))
}
const retryable: Bbox[] = []
const worker = async (): Promise<void> => {
while (next < jobs.length) {
if (controller.signal.aborted || runId !== runIdRef.current) return
const job = jobs[next++]
if (!(await collect(job))) retryable.push(job)
done++
if (controller.signal.aborted || runId !== runIdRef.current) return
publish()
await sleep(REQUEST_SPACING_MS, controller.signal)
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENT_REQUESTS, jobs.length) }, worker))
if (controller.signal.aborted || runId !== runIdRef.current) return
// One more pass over what did not answer. Overpass mirrors time out under load far
// more often than they are truly empty, and reporting "4 stretches went unsearched"
// when a second ask would have answered them is the wrong end of the trade.
for (const job of retryable) {
if (controller.signal.aborted || runId !== runIdRef.current) return
if (await collect(job)) continue
failures++
setFailedAreas(failures)
}
publish()
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [spine, categories, widthKm, locale])
useEffect(() => () => abortRef.current?.abort(), [])
return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
}
@@ -1,237 +0,0 @@
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
import type { RoadtripVias } from './useRoadtripVias'
import type { TrackPlace } from './useFollowTrack'
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
import { useFollowTrack } from './useFollowTrack'
/**
* The hook is the part that talks to two services and has to survive both of them going
* wrong halfway. The refinement itself is `followTrack`'s business and tested there; what
* matters here is what reaches the day and what reaches the traveller.
*/
const START = { lat: 52, lng: 13 }
const END = { lat: 52, lng: 14 }
/** A track arching north away from the straight line between the day's two stops. */
function arc(points = 60, bulgeDeg = 0.1): [number, number][] {
return Array.from({ length: points + 1 }, (_, i) => [
52 + bulgeDeg * Math.sin((Math.PI * i) / points),
13 + i / points,
])
}
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7, dayNumber: 1, date: null, title: null,
stops: [
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
],
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
distance: 0, duration: 0,
...over,
}
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
return {
byDay: {},
trackByDay: {},
stale: false,
editable: true,
add: vi.fn(),
addMany: vi.fn().mockResolvedValue(undefined),
move: vi.fn(),
remove: vi.fn(),
reanchor: vi.fn(),
...over,
}
}
const TRACK: TrackPlace = { id: 3, name: 'Atlantic Road', route_geometry: JSON.stringify(arc()), route_color: '#0ea5e9' }
const PLAIN: TrackPlace = { id: 4, name: 'A hotel', route_geometry: null }
const FAR: TrackPlace = { id: 5, name: 'Somewhere else', route_geometry: JSON.stringify(arc().map(([la, ln]) => [la + 4, ln])) }
/**
* A router with no idea about roads: it drives straight from waypoint to waypoint.
*
* Which is exactly what makes the refinement work for its living — each via added bends
* the answer a little closer to the arc, so the loop converges instead of running to its
* cap the way a fixed answer would.
*/
function answersStraight(): void {
calculateRouteWithLegs.mockImplementation(async (waypoints: { lat: number; lng: number }[]) => ({
coordinates: waypoints.map(w => [w.lat, w.lng]),
distance: 0, duration: 0, legs: [],
}))
}
beforeEach(() => {
calculateRouteWithLegs.mockReset()
})
describe('useFollowTrack', () => {
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
expect(result.current.dayId).toBeNull()
// Tens of thousands of points per track, for a dialog nobody opened.
expect(result.current.tracks).toEqual([])
// But it still knows there is something to open, without parsing a single point.
expect(result.current.available).toBe(true)
})
it('FE-FOLLOWHOOK-011: the track a day follows is read back from the day, not remembered', () => {
// The whole point of storing it: a field that only ever held what this session did
// would look right until somebody refreshed the page.
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 3, stray_km: 0.8 } } });
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
expect(result.current.namesByDay).toEqual({ 7: 'Atlantic Road' })
act(() => { result.current.open(7) })
expect(result.current.current).toEqual({ name: 'Atlantic Road', strayKm: 0.8 })
})
it('FE-FOLLOWHOOK-012: a stored track whose place is gone names nothing', () => {
// The row cascades with the place, so this is belt and braces — but a label that
// outlived its line would be a day claiming to follow something invisible.
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 999, stray_km: null } } })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
expect(result.current.current).toBeNull()
})
it('FE-FOLLOWHOOK-010: a trip that imported nothing offers no way in', () => {
const { result } = renderHook(() => useFollowTrack(1, [PLAIN], routes([day()]), viasStub()))
expect(result.current.available).toBe(false)
})
it('FE-FOLLOWHOOK-002: opening lists only the places that carry a line, nearest first', () => {
const { result } = renderHook(() => useFollowTrack(1, [FAR, PLAIN, TRACK], routes([day()]), viasStub()))
act(() => { result.current.open(7) })
expect(result.current.tracks.map(t => t.id)).toEqual([3, 5])
expect(result.current.tracks[0].name).toBe('Atlantic Road')
expect(result.current.tracks[0].color).toBe('#0ea5e9')
expect(result.current.tracks[0].lengthKm).toBeGreaterThan(60)
expect(result.current.tracks[0].gapKm).toBeLessThan(result.current.tracks[1].gapKm)
})
it('FE-FOLLOWHOOK-003: applying writes the plan onto the day, legs and all', async () => {
answersStraight()
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(vias.addMany).toHaveBeenCalledTimes(1)
const [dayId, written, legs, track] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
expect(dayId).toBe(7)
expect(legs).toEqual([0])
// The chain and the name it came from land in one write: a day claiming to follow a
// road whose vias never arrived would be worse than a day claiming nothing.
expect(track).toMatchObject({ place_id: 3 })
expect(written.length).toBeGreaterThan(0)
// Every via hangs behind the first stop, because the day has exactly one leg.
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
expect(result.current.outcome?.vias).toBe(written.length)
expect(result.current.busy).toBe(false)
})
it('FE-FOLLOWHOOK-004: a track the drive already follows writes nothing but says so', async () => {
// The router answers with the track itself, so there is nothing left to pull onto.
calculateRouteWithLegs.mockResolvedValue({ coordinates: arc(), distance: 0, duration: 0, legs: [] })
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
// Still a write: the legs are cleared so a second track does not stack, and the name
// is recorded even though the drive needed no bending to follow it.
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], { place_id: 3, stray_km: 0 })
})
it('FE-FOLLOWHOOK-005: a routing outage is reported rather than written as an empty plan', async () => {
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(result.current.error).toBe('route')
expect(result.current.busy).toBe(false)
expect(vias.addMany).not.toHaveBeenCalled()
// One retry, because a refinement is several calls against a rate-limited host.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
}, 10000)
it('FE-FOLLOWHOOK-006: a save that fails says so instead of claiming the day changed', async () => {
answersStraight()
const vias = viasStub({ addMany: vi.fn().mockRejectedValue(new Error('boom')) })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(result.current.error).toBe('save')
expect(result.current.outcome).toBeNull()
expect(result.current.busy).toBe(false)
}, 10000)
it('FE-FOLLOWHOOK-007: clearing drops the vias of every leg of the day', async () => {
const vias = viasStub({ byDay: { 7: [{ id: 1 }, { id: 2 }] as never } })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
expect(result.current.viaCount).toBe(2)
await act(async () => { await result.current.clear() })
// null, not nothing: dropping the points is also dropping the claim.
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null)
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
})
it('FE-FOLLOWHOOK-008: closing abandons a run rather than letting it land afterwards', async () => {
let release: ((value: unknown) => void) | null = null
calculateRouteWithLegs.mockImplementation(() => new Promise(resolve => { release = resolve }))
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
let running: Promise<void> | null = null
act(() => { running = result.current.apply(3) })
await waitFor(() => { expect(result.current.busy).toBe(true) })
act(() => { result.current.close() })
await act(async () => {
release?.({ coordinates: [[START.lat, START.lng], [END.lat, END.lng]], distance: 0, duration: 0, legs: [] })
await running
})
// The day is left exactly as it was: an abandoned plan is not half a chain of vias.
expect(vias.addMany).not.toHaveBeenCalled()
expect(result.current.dayId).toBeNull()
expect(result.current.error).toBeNull()
})
it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => {
answersStraight()
const vias = viasStub()
const lonely = day({ stops: [day().stops[0]] })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([lonely]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
expect(vias.addMany).not.toHaveBeenCalled()
})
})
@@ -1,255 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
import type { LatLng } from './corridor'
import type { RoadtripRoutes } from './useRoadtripRoutes'
import type { RoadtripVias } from './useRoadtripVias'
/**
* The dialog state behind "this day follows that track".
*
* Kept out of the dialog because applying one is a long job — a round trip to the routing
* host per refinement — and a component that unmounts halfway would leave the day with
* half a chain of vias on it. Here it can be aborted, and here it can say how far it has
* got while it runs.
*/
/** A track of this trip, ready to be offered. */
export interface TrackChoice {
id: number
name: string
/** The colour the map already draws it in, so the list and the line agree. */
color: string | null
points: LatLng[]
lengthKm: number
/** How far the track runs from the open day at its closest — what orders the list. */
gapKm: number
}
/** What became of an applied track. */
export interface FollowTrackOutcome {
vias: number
strayKm: number
capped: boolean
}
export interface FollowTrack {
/** The day the dialog is open for, or null when it is closed. */
dayId: number | null
open: (dayId: number) => void
close: () => void
/** The trip's tracks, nearest to this day first. Empty until the dialog opens. */
tracks: TrackChoice[]
busy: boolean
/** Which refinement round is running, for a dialog that has to say something. */
round: number
error: 'route' | 'save' | null
outcome: FollowTrackOutcome | null
apply: (trackId: number) => Promise<void>
/** Drops every via of the open day, so it drives the way the router would. */
clear: () => Promise<void>
/** How many vias the open day carries — what makes clearing worth offering. */
viaCount: number
/**
* The track the open day follows, if it follows one.
*
* Read back from the day rather than remembered from the run that applied it: the point
* of storing it at all is that it survives a reload, and a field that only ever held
* what this session did would look right until somebody refreshed.
*/
current: { name: string; strayKm: number | null } | null
/** The name of the track each day follows, for the rail's badge. */
namesByDay: Record<number, string>
/**
* Whether the trip holds a track at all.
*
* Read off the column rather than off `tracks`, which is empty until the dialog opens
* and would therefore never let it be opened. It is what keeps the rail from offering
* a control whose only possible answer is "no tracks in this trip".
*/
available: boolean
}
/** A place is a track when it carries a line. */
export interface TrackPlace {
id: number
name?: string | null
route_geometry?: string | null
route_color?: string | null
}
/** Waiting a beat between rounds: the public routing hosts allow about one call a second. */
export const ROUND_PAUSE_MS = 400
/** How long to wait before the one retry a round gets. */
export const RETRY_PAUSE_MS = 1500
const sleep = (ms: number): Promise<void> => new Promise(resolve => { setTimeout(resolve, ms) })
export function useFollowTrack(
tripId: number | string | null,
places: TrackPlace[],
routes: RoadtripRoutes,
vias: RoadtripVias,
): FollowTrack {
const [dayId, setDayId] = useState<number | null>(null)
const [busy, setBusy] = useState(false)
const [round, setRound] = useState(0)
const [error, setError] = useState<'route' | 'save' | null>(null)
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
const abort = useRef<AbortController | null>(null)
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
/**
* Parsed only while the dialog is open.
*
* A track runs to tens of thousands of points and a trip can hold a dozen of them, so
* parsing them to render a rail nobody opened would cost more than the rail does. The
* memo hangs off `dayId` as well as `places` for exactly that reason.
*/
const tracks = useMemo<TrackChoice[]>(() => {
if (dayId === null) return []
const found: TrackChoice[] = []
for (const place of places) {
const points = parseTrack(place.route_geometry)
if (points.length < 2) continue
found.push({
id: place.id,
name: place.name || '',
color: place.route_color ?? null,
points,
lengthKm: lineLengthKm(points),
gapKm: trackGapKm(points, stops),
})
}
// Nearest first: a trip carrying every track of a three-week tour should still open
// on the one that runs along today's drive.
return found.sort((a, b) => a.gapKm - b.gapKm)
}, [dayId, places, stops])
const open = useCallback((next: number) => {
setDayId(next)
setOutcome(null)
setError(null)
setRound(0)
}, [])
const close = useCallback(() => {
abort.current?.abort()
abort.current = null
setDayId(null)
setBusy(false)
setRound(0)
}, [])
const apply = useCallback(async (trackId: number) => {
const track = tracks.find(t => t.id === trackId)
if (!track || dayId === null || stops.length < 2 || busy) return
const controller = new AbortController()
abort.current = controller
setBusy(true)
setError(null)
setOutcome(null)
setRound(0)
let plan: FollowTrackPlan
let spent = 0
try {
plan = await planTrack({
track: track.points,
stops,
signal: controller.signal,
onRound: n => { setRound(n) },
route: async waypoints => {
// Paced before the call rather than after it, so a plan that fits on its first
// round costs nothing in waiting — and given one second chance, because a
// refinement is several calls in a row against a host that allows about one a
// second, and losing the whole loop to a single 429 would restart it at nothing.
if (spent > 0) await sleep(ROUND_PAUSE_MS)
spent += 1
for (let attempt = 0; ; attempt++) {
try {
const r = await calculateRouteWithLegs(waypoints, {
signal: controller.signal,
profile: 'driving',
tripId,
dayId,
})
return r.coordinates.map(([lat, lng]) => ({ lat, lng }))
} catch (err) {
if (attempt >= 1 || controller.signal.aborted) throw err
await sleep(RETRY_PAUSE_MS)
}
}
},
})
} catch {
if (!controller.signal.aborted) setError('route')
setBusy(false)
return
}
if (controller.signal.aborted) { setBusy(false); return }
try {
// The chain and the name it came from, in one write: a day claiming to follow a
// road whose vias never landed would be worse than a day claiming nothing.
await vias.addMany(dayId, plan.vias, plan.legs, { place_id: trackId, stray_km: plan.strayKm })
setOutcome({ vias: plan.vias.length, strayKm: plan.strayKm, capped: plan.capped })
} catch {
setError('save')
} finally {
setBusy(false)
}
}, [tracks, dayId, stops, busy, tripId, vias])
const viaCount = dayId === null ? 0 : (vias.byDay[dayId]?.length ?? 0)
/**
* The name behind each day's stored track id.
*
* A lookup over `places` rather than a parse: naming a line costs a row, and the rail
* asks for this on every render.
*/
const namesByDay = useMemo(() => {
const names: Record<number, string> = {}
const byId = new Map(places.map(p => [p.id, p]))
for (const [day, track] of Object.entries(vias.trackByDay)) {
const place = byId.get(track.place_id)
if (place?.name) names[Number(day)] = place.name
}
return names
}, [places, vias.trackByDay])
const current = useMemo(() => {
if (dayId === null) return null
const track = vias.trackByDay[dayId]
const name = namesByDay[dayId]
return track && name ? { name, strayKm: track.stray_km } : null
}, [dayId, vias.trackByDay, namesByDay])
// A cheap look at the column, not a parse: this runs on every planner render, and the
// question is only whether anything at all was ever imported.
const available = useMemo(() => places.some(p => (p.route_geometry?.length ?? 0) > 2), [places])
const clear = useCallback(async () => {
if (dayId === null || busy) return
setBusy(true)
setError(null)
try {
// Every leg of the day, so nothing is left behind on a leg the track never reached.
const legs = Array.from({ length: Math.max(0, stops.length - 1) }, (_, i) => i)
// null rather than nothing: dropping the points is also dropping the claim.
await vias.addMany(dayId, [], legs, null)
setOutcome({ vias: 0, strayKm: 0, capped: false })
} catch {
setError('save')
} finally {
setBusy(false)
}
}, [dayId, busy, stops.length, vias])
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available, current, namesByDay }
}
@@ -1,174 +0,0 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CorridorPoi, CorridorSearch } from './useCorridorPois'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() }))
vi.mock('./useCorridorPois', () => ({ useCorridorPois }))
import { useRoadtripCorridor } from './useRoadtripCorridor'
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
return {
lat: 53, lng: 10, category: 'fuel', poi_type: 'fuel', address: null, website: null,
phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap',
offRouteKm: 1, alongKm: 10, brand: null, brand_wikidata: null,
...over,
} as CorridorPoi
}
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 1, dayNumber: 1, date: null, title: null,
stops: [
{ assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
],
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
distance: 0, duration: 0,
...over,
}
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
/** The search state the mocked hook hands back, results included. */
function searchWith(results: CorridorPoi[]): CorridorSearch {
return {
results,
progress: { done: 0, total: 0 },
loading: false,
capped: false,
failedAreas: 0,
truncatedAreas: 0,
error: false,
// The thinned line the day's geometry becomes; hits and stops are measured along it.
spine: [{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }],
search: vi.fn(),
clear: vi.fn(),
}
}
beforeEach(() => {
useCorridorPois.mockReset()
})
describe('useRoadtripCorridor', () => {
it('FE-ROADTRIP-CORRIDORSTATE-001: with no filter everything found is visible', () => {
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
useCorridorPois.mockReturnValue(searchWith(hits))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
expect(result.current.visible).toBe(hits)
})
it('FE-ROADTRIP-CORRIDORSTATE-002: the filter matches part of a name, ignoring case', () => {
useCorridorPois.mockReturnValue(searchWith([
poi({ osm_id: 'a', name: 'Aral Autohof' }),
poi({ osm_id: 'b', name: 'Shell Nord' }),
]))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('shell'))
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
})
it('FE-ROADTRIP-CORRIDORSTATE-003: the brand counts as a name, because the server folds the operator into it', () => {
useCorridorPois.mockReturnValue(searchWith([
poi({ osm_id: 'a', name: 'Autohof Nord', brand: 'Shell' }),
poi({ osm_id: 'b', name: 'Raststätte Süd', brand: 'Aral' }),
]))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('Shell'))
expect(result.current.visible.map(p => p.osm_id)).toEqual(['a'])
})
it('FE-ROADTRIP-CORRIDORSTATE-004: whitespace alone is not a filter', () => {
const hits = [poi({ osm_id: 'a', name: 'Aral' })]
useCorridorPois.mockReturnValue(searchWith(hits))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter(' '))
expect(result.current.visible).toBe(hits)
})
it('FE-ROADTRIP-CORRIDORSTATE-005: starting a new search clears the old question', () => {
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('aral'))
expect(result.current.nameFilter).toBe('aral')
// A search begins: keeping the term would hide the new hits behind the old query.
useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true })
rerender()
expect(result.current.nameFilter).toBe('')
})
it('FE-ROADTRIP-CORRIDORSTATE-007: typing while the search is still running survives the next batch', () => {
// Hits arrive box by box and publish as they land. Clearing on every batch instead of
// on the start of a search wiped out whatever had been typed in the meantime.
useCorridorPois.mockReturnValue({ ...searchWith([poi({ osm_id: 'a', name: 'Aral' })]), loading: true })
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('shell'))
useCorridorPois.mockReturnValue({
...searchWith([poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]),
loading: true,
})
rerender()
expect(result.current.nameFilter).toBe('shell')
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
})
it('FE-ROADTRIP-CORRIDORSTATE-006: the visible list keeps its identity while nothing changes', () => {
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
const before = result.current.visible
rerender()
// Both map renderers rebuild every marker when this array's identity changes.
expect(result.current.visible).toBe(before)
})
it('FE-ROADTRIP-CORRIDORSTATE-020: narrowing to one point keeps only what is near it', () => {
// A seven-hundred-kilometre day turns up hits along the whole of it. Picking the point
// you mean to break at is a subtraction, because both figures are measured against
// the same thinned line rather than being asked for separately.
useCorridorPois.mockReturnValue(searchWith([
poi({ osm_id: 'near', name: 'Near', alongKm: 30 }),
poi({ osm_id: 'far', name: 'Far', alongKm: 600 }),
]))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => { result.current.setSectionKm(50) })
act(() => { result.current.setSection({ dayId: 1, kind: 'stop', index: 0 }) })
// Stop 0 sits at the start of the drive, so only the hit within 50 km survives.
expect(result.current.visible.map(p => p.osm_id)).toEqual(['near'])
})
it('FE-ROADTRIP-CORRIDORSTATE-021: an anchor belonging to another day narrows nothing', () => {
// The section names a day as well as an index, so switching days cannot silently
// apply yesterday's break to today's drive.
const hits = [poi({ osm_id: 'a', name: 'A', alongKm: 10 }), poi({ osm_id: 'b', name: 'B', alongKm: 600 })]
useCorridorPois.mockReturnValue(searchWith(hits))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => { result.current.setSection({ dayId: 99, kind: 'stop', index: 0 }) })
expect(result.current.visible).toBe(hits)
})
})
@@ -1,215 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCorridorPois'
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
import type { SectionAnchor } from './roadtripModel'
import { projectOntoRoute, type LatLng } from './corridor'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
// The categories come from the one table now, which is also the only place that knows
// a hotel is a category without being a stop kind.
export { CORRIDOR_CATEGORY_KEYS } from './stopKinds'
/** Corridor widths offered, in kilometres. */
export const CORRIDOR_WIDTHS_KM = [2, 5, 10]
/** How far either side of a chosen point the list is narrowed to, in kilometres. */
export const CORRIDOR_SECTION_KM = [25, 50, 100]
export interface RoadtripCorridor {
/** Which day's drive is being searched. */
dayId: string
setDayId: (value: string) => void
day: RoadtripDay | undefined
categories: string[]
toggleCategory: (key: string) => void
widthKm: number
setWidthKm: (km: number) => void
search: CorridorSearch
/** Narrows what was found by name or brand. Empty means everything. */
nameFilter: string
/** The stops and leg midpoints a break can be planned around. */
anchors: SectionAnchor[]
/** Which of them the list is narrowed to, or null for the whole day. */
section: { dayId: number; kind: 'stop' | 'leg'; index: number } | null
setSection: (value: { dayId: number; kind: 'stop' | 'leg'; index: number } | null) => void
/** How far either side of it, in kilometres. */
sectionKm: number
setSectionKm: (value: number) => void
/** Socket family the charging hits are narrowed to, or empty for any. */
socketFilter: string
setSocketFilter: (value: string) => void
/** Minimum kW for charging hits, or 0 for any. */
minKw: number
setMinKw: (value: number) => void
setNameFilter: (value: string) => void
/**
* What both the panel and the map show: the hits that match `nameFilter`.
* Read this, never `search.results`, or the two drift apart.
*/
visible: CorridorPoi[]
/**
* Which position in the day's chain a hit belongs at, so adding one lands it in the
* order it will actually be driven past rather than at the end of the day.
*/
insertIndexFor: (poi: CorridorPoi) => number
/** How far along the drive each of the day's stops sits, in the same units as a hit. */
stopsAlongKm: number[]
}
/**
* The corridor search as trip state rather than panel state.
*
* The map has to draw what the search found — a list of petrol stations is only half an
* answer if you cannot see which one is on your side of the road — so the results have to
* live above both the panel and the map rather than inside the panel.
*/
export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
const [dayId, setDayId] = useState<string>('')
const [categories, setCategories] = useState<string[]>(['fuel'])
const [widthKm, setWidthKm] = useState<number>(5)
const [nameFilter, setNameFilter] = useState('')
/**
* Which point of the day the list is narrowed around, if any.
*
* Stored as the anchor's identity rather than as a distance, on purpose: `alongKm` is
* measured against the thinned spine, whose length depends on the corridor width
* (`useCorridorPois` thins with a tolerance derived from it). A kilometre figure kept
* here would quietly shift when the width changes from 5 to 10; resolved through
* `stopsAlongKm` it lands on the same stop again.
*/
const [section, setSection] = useState<{ dayId: number; kind: 'stop' | 'leg'; index: number } | null>(null)
const [sectionKm, setSectionKm] = useState(50)
/** A socket family, or empty for any. Only ever applied to charging hits. */
const [socketFilter, setSocketFilter] = useState('')
/** Minimum kW, or 0 for any. */
const [minKw, setMinKw] = useState(0)
// Falls back to the first day with a drive, so the panel is useful before the user
// has picked anything — and follows along when that day disappears.
const day = routes.days.find(d => String(d.dayId) === dayId) ?? routes.days[0]
const line = useMemo<LatLng[]>(() => {
if (!day) return []
// The roads driven, not the straight lines between stops. A corridor built from the
// stops searches beside a line the car never takes — between two cities that is open
// country, which is why everything it found sat at the stops themselves.
if (day.geometry.length > 1) return day.geometry.map(([lat, lng]) => ({ lat, lng }))
// Until the day has routed there is nothing else to go on.
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day])
const search = useCorridorPois(line, categories, widthKm)
const toggleCategory = useCallback((key: string) => {
setCategories(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]))
}, [])
// A filter kept across a new search would hide the fresh answer behind the old question.
// Cleared when a search STARTS, not whenever results change: hits arrive box by box and
// publish as they land, so resetting on every batch wiped out anything typed while the
// search was still running.
const searching = search.loading
const wasSearching = useRef(false)
useEffect(() => {
if (searching && !wasSearching.current) setNameFilter('')
wasSearching.current = searching
}, [searching])
/**
* Where a hit belongs in the day's chain, as an index among its stops.
*
* The stops are projected onto the same thinned line the hits were measured against, so
* both sides are distances along one drive and the comparison is simply "which stops has
* the car passed by then". A stop the projection cannot place (nowhere near the routed
* line) keeps its neighbours' order rather than jumping to the front.
*/
const stopsAlongKm = useMemo(() => {
if (!day || search.spine.length < 2) return []
let last = 0
return day.stops.map(s => {
const hit = projectOntoRoute({ lat: s.lat, lng: s.lng }, search.spine)
if (hit) last = hit.alongKm
return last
})
}, [day, search.spine])
/**
* The stops and leg midpoints of the current day, and the distance of the chosen one.
*
* Resolved here rather than stored: `section` names an anchor, and its kilometre figure
* is looked up fresh, so changing the corridor width does not move the point the list
* is narrowed around.
*/
const anchors = useMemo(() => sectionAnchors(stopsAlongKm), [stopsAlongKm])
const anchorKm = useMemo(() => {
if (!section || !day || section.dayId !== day.dayId) return null
return anchors.find(a => a.kind === section.kind && a.index === section.index)?.alongKm ?? null
}, [section, day, anchors])
/**
* Memoised on purpose, not filtered where it is drawn: both map renderers tear down and
* rebuild every POI marker whenever the array's identity changes, so a `.filter()` in a
* render body would make the pins flicker on each keystroke and on every unrelated
* re-render.
*
* Brand counts as a name here because the server folds `operator` into `brand`, and
* "Shell" is what someone types when the OSM name is "Shell Autohof Nord".
*/
const visible = useMemo(() => {
const needle = nameFilter.trim().toLowerCase()
// The same array when nothing narrows it, not a copy of it: the map redraws off this
// reference, and handing it a fresh array on every render moves every pin.
if (!needle && !socketFilter && !minKw && anchorKm === null) return search.results
return search.results.filter(p => {
// Around one point of the drive, when one has been picked. The two figures are
// measured against the same thinned line, so this really is a subtraction and not
// a second search.
if (anchorKm !== null && Math.abs(p.alongKm - anchorKm) > sectionKm) return false
if (needle && !(p.name.toLowerCase().includes(needle) || (p.brand ?? '').toLowerCase().includes(needle))) {
return false
}
// The charging filters only ever hide charging hits. A rest area does not have a
// socket and is not answering the question, so filtering the whole list by one
// would empty it of everything the search also found.
if (p.category !== 'charging') return true
if (socketFilter && !p.charging?.sockets.some(s => s.type === socketFilter)) return false
// A station that does not state its power is kept. Roughly two thirds of them do
// not, and reading silence as "too slow" would throw away most of the map.
if (minKw && p.charging?.sockets.some(s => s.kw != null) && !p.charging.sockets.some(s => (s.kw ?? 0) >= minKw)) {
return false
}
return true
})
}, [search.results, nameFilter, socketFilter, minKw, anchorKm, sectionKm])
const insertIndexFor = useCallback(
(poi: CorridorPoi) => insertIndexForAlong(stopsAlongKm, poi.alongKm),
[stopsAlongKm],
)
return {
dayId: day ? String(day.dayId) : '',
setDayId,
day,
categories,
toggleCategory,
widthKm,
setWidthKm,
search,
nameFilter,
anchors,
section,
setSection,
sectionKm,
setSectionKm,
socketFilter,
setSocketFilter,
minKw,
setMinKw,
setNameFilter,
visible,
insertIndexFor,
stopsAlongKm,
}
}
@@ -1,371 +0,0 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { Assignment, AssignmentsMap, Day } from '../../types'
// Hoisted together with the mock: the module factory runs before the file body, so a
// class declared down there would not exist yet when the hook does its `instanceof`.
const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
class RoutingRefusedError extends Error {
constructor(readonly status: number, readonly retryAfterMs: number | null) {
super('refused')
this.name = 'RoutingRefusedError'
}
get isRateLimit(): boolean { return this.status === 429 || this.status === 503 }
}
return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
})
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
import { useRoadtripRoutes } from './useRoadtripRoutes'
const HAMBURG: [number, number] = [53.5511, 9.9937]
const LUENEBURG: [number, number] = [53.2464, 10.4115]
const BERLIN: [number, number] = [52.52, 13.405]
interface StopSpec {
id: number
at: [number, number]
time?: string | null
dwell?: number | null
legMode?: string | null
incoming?: string | null
stopType?: string | null
noCoords?: boolean
}
function day(id: number, number: number, extra: Partial<Day> = {}): Day {
return { id, day_number: number, date: null, title: null, ...extra } as unknown as Day
}
function assignment(spec: StopSpec, order: number): Assignment {
return {
id: spec.id,
place_id: spec.id * 10,
order_index: order,
assignment_time: spec.time ?? null,
leg_transport_mode: spec.legMode ?? null,
incoming_leg_transport_mode: spec.incoming ?? null,
place: {
id: spec.id * 10,
name: `Stop ${spec.id}`,
lat: spec.noCoords ? null : spec.at[0],
lng: spec.noCoords ? null : spec.at[1],
place_time: null,
duration_minutes: spec.dwell ?? null,
stop_type: spec.stopType ?? null,
},
} as unknown as Assignment
}
const map = (dayId: number, stops: StopSpec[]): AssignmentsMap =>
({ [String(dayId)]: stops.map(assignment) }) as unknown as AssignmentsMap
/** A router answer with one leg per consecutive pair, the way OSRM replies. */
const routed = (legs: number, coordinates: [number, number][] = [HAMBURG, BERLIN]) => ({
coordinates,
distance: 100000,
duration: 3600,
legs: Array.from({ length: legs }, () => ({ distance: 100000 / legs, duration: 3600 / legs, text: '100 km' })),
})
beforeEach(() => {
calculateRouteWithLegs.mockReset()
calculateRouteWithLegs.mockResolvedValue(routed(1))
})
afterEach(() => {
vi.useRealTimers()
})
describe('useRoadtripRoutes', () => {
it('FE-ROADTRIP-ROUTES-001: routes a whole day in one request, not one per leg', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [
{ id: 1, at: HAMBURG },
{ id: 2, at: LUENEBURG },
{ id: 3, at: BERLIN },
]
calculateRouteWithLegs.mockResolvedValue(routed(2))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
const [waypoints, options] = calculateRouteWithLegs.mock.calls[0]
expect(waypoints).toHaveLength(3)
expect(options).toMatchObject({ profile: 'driving', tripId: 7, dayId: 1 })
})
it('FE-ROADTRIP-ROUTES-002: a day whose legs are driven and cycled becomes two requests', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [
{ id: 1, at: HAMBURG, legMode: 'driving' },
{ id: 2, at: LUENEBURG, legMode: 'cycling' },
{ id: 3, at: BERLIN },
]
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
const profiles = calculateRouteWithLegs.mock.calls.map(c => c[1].profile)
expect(profiles).toEqual(['driving', 'cycling'])
})
it("FE-ROADTRIP-ROUTES-003: the day's own default mode wins over the trip fallback", async () => {
const days = [day(1, 1, { default_transport_mode: 'walking' } as Partial<Day>)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }]
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving'))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs.mock.calls[0][1].profile).toBe('walking')
})
it('FE-ROADTRIP-ROUTES-004: drops a stop without coordinates and a day left with one stop', async () => {
const days = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG, noCoords: true }]),
...map(2, [{ id: 3, at: HAMBURG }, { id: 4, at: BERLIN }]),
} as AssignmentsMap
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
// Day 1 lost its second stop and with it its reason to appear at all.
expect(result.current.days.map(d => d.dayId)).toEqual([2])
expect(result.current.totalStops).toBe(2)
})
it('FE-ROADTRIP-ROUTES-013: a charger on the way is part of the drive, not a stop', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [
{ id: 1, at: HAMBURG },
{ id: 2, at: [53, 11], stopType: 'charging' },
{ id: 3, at: BERLIN },
]
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, [53, 11], BERLIN]))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await waitFor(() => expect(result.current.loading).toBe(false))
// Three assignments, three markers on the map, two places the trip is for. The
// charger still routes — it is only left out of the count the head shows.
expect(result.current.days[0].stops).toHaveLength(3)
expect(result.current.totalStops).toBe(2)
})
it('FE-ROADTRIP-ROUTES-005: sums the legs and hands the map one polyline per routed run', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
calculateRouteWithLegs.mockResolvedValue(routed(1, [HAMBURG, [53, 11], BERLIN]))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.totalDistance).toBe(100000)
expect(result.current.totalDuration).toBe(3600)
expect(result.current.lines).toEqual([[HAMBURG, [53, 11], BERLIN]])
// The corridor search reads this: the roads driven, not the line between stops.
expect(result.current.days[0].geometry).toEqual([HAMBURG, [53, 11], BERLIN])
expect(result.current.segments).toHaveLength(1)
})
it('FE-ROADTRIP-ROUTES-006: walks the clock forward from the first pinned time', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [
{ id: 1, at: HAMBURG, time: '09:00', dwell: 30 },
{ id: 2, at: BERLIN, dwell: 60 },
]
// One hour of driving between them.
calculateRouteWithLegs.mockResolvedValue(routed(1))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await waitFor(() => expect(result.current.loading).toBe(false))
const entries = result.current.days[0].schedule.entries
expect(entries[0]).toMatchObject({ arrival: '09:00', departure: '09:30', anchored: true })
expect(entries[1]).toMatchObject({ arrival: '10:30', departure: '11:30', anchored: false })
})
it('FE-ROADTRIP-ROUTES-007: retries a leg that did not route, then keeps it blank', async () => {
vi.useFakeTimers()
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
// Two backoffs, then it gives up rather than inventing a duration.
await act(async () => { await vi.advanceTimersByTimeAsync(8000) })
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
expect(result.current.loading).toBe(false)
expect(result.current.days[0].legs).toEqual([undefined])
expect(result.current.totalDistance).toBe(0)
// A leg that never routed breaks the chain instead of guessing past it.
expect(result.current.days[0].schedule.entries[1].arrival).toBeNull()
})
it('FE-ROADTRIP-ROUTES-008: a leg that answers on the second try still lands', async () => {
vi.useFakeTimers()
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
calculateRouteWithLegs
.mockRejectedValueOnce(new Error('429'))
.mockResolvedValue(routed(1))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await act(async () => { await vi.advanceTimersByTimeAsync(3000) })
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
expect(result.current.totalDistance).toBe(100000)
})
it('FE-ROADTRIP-ROUTES-009: renaming a place does not re-ask the router', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
const first = map(1, stops)
const { result, rerender } = renderHook(
({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
{ initialProps: { assignments: first } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
const renamed = JSON.parse(JSON.stringify(first)) as AssignmentsMap
renamed['1'][0].place!.name = 'Somewhere else entirely'
rerender({ assignments: renamed })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-ROUTES-010: a trip with nothing to drive asks for nothing', async () => {
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, [{ id: 1, at: HAMBURG }])))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
expect(result.current.days).toEqual([])
expect(result.current.totalStops).toBe(0)
})
it('FE-ROADTRIP-ROUTES-012: a stop whose id changes after saving keeps its legs and its clock', async () => {
// The optimistic insert: a stop added mid-day carries a temporary negative id until
// the server answers, then the real id replaces it without a coordinate moving. Since
// `planKey` is geometry only, no refetch follows — so legs filed under the id would be
// stranded, and a broken chain blanks every arrival after it.
calculateRouteWithLegs.mockResolvedValue(routed(2))
const days = [day(1, 1)]
const optimistic: StopSpec[] = [
{ id: 1, at: HAMBURG, time: '09:00' },
{ id: -1755000000, at: LUENEBURG },
{ id: 2, at: BERLIN },
]
const first = map(1, optimistic)
const { result, rerender } = renderHook(
({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
{ initialProps: { assignments: first } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
const saved = JSON.parse(JSON.stringify(first)) as AssignmentsMap
saved['1'][1].id = 4711
rerender({ assignments: saved })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
expect(result.current.days[0].schedule.entries[2].arrival).not.toBeNull()
})
it('FE-ROADTRIP-ROUTES-013: a rate-limited host is given the time it asked for', async () => {
// The public OSRM instances answer 429 above roughly one request a second and say how
// long to wait. Retrying sooner than that is just a second refusal, so the host's
// Retry-After wins over the shorter backoff the hook would otherwise use.
vi.useFakeTimers()
calculateRouteWithLegs
.mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
.mockResolvedValue(routed(1))
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
// The hook's own first backoff is 1500 ms; at that point it must still be waiting.
await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
expect(result.current.days[0].legs[0]).toBeDefined()
})
it('FE-ROADTRIP-ROUTES-014: a via joins the routing request between the stops it follows', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
calculateRouteWithLegs.mockResolvedValue(routed(2))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
}))
await waitFor(() => expect(result.current.loading).toBe(false))
// Three waypoints for two stops: the via is threaded in where it belongs.
const [waypoints] = calculateRouteWithLegs.mock.calls[0]
expect(waypoints).toHaveLength(3)
expect(waypoints[1]).toEqual({ lat: 53.2, lng: 10.4 })
})
it('FE-ROADTRIP-ROUTES-015: the two legs a via creates are shown as the one drive they are', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
// The router answers per waypoint pair: two legs for one stop pair.
calculateRouteWithLegs.mockResolvedValue(routed(2))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
}))
await waitFor(() => expect(result.current.loading).toBe(false))
// The rail shows one leg between two stops, carrying the whole drive.
expect(result.current.days[0].legs).toHaveLength(1)
expect(result.current.days[0].legs[0]).toMatchObject({ distance: 100000, duration: 3600 })
})
it('FE-ROADTRIP-ROUTES-016: moving a via re-asks the router, renaming a place still does not', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }] }
const { result, rerender } = renderHook(
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, days, map(1, stops), 'driving', v),
{ initialProps: { v: vias } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
// A via that moved is a different road, so the route has to be asked for again.
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.9 }] } })
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
})
it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
let seen: AbortSignal | undefined
calculateRouteWithLegs.mockImplementation((_wp: unknown, opts: { signal: AbortSignal }) => {
seen = opts.signal
return new Promise(() => {}) // never settles
})
const { unmount } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await waitFor(() => expect(seen).toBeDefined())
expect(seen!.aborted).toBe(false)
unmount()
expect(seen!.aborted).toBe(true)
})
})
@@ -1,538 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
import {
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning,
} from './roadtripModel'
import { projectOntoRoute } from './corridor'
import { useSettingsStore } from '../../store/settingsStore'
import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
import { spurFor } from './accessSpur'
import type { RoadtripVia } from '@trek/shared'
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
export interface RoadtripStop {
assignmentId: number
placeId: number
name: string
lat: number
lng: number
/** Planned time on the assignment, or the place's own time as the fallback the API already resolves. */
time: string | null
/** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */
dwellMinutes: number | null
/** Mode of the leg LEAVING this stop; null inherits the day default. */
legMode: string | null
incomingLegMode: string | null
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
stopType: string | null
/**
* How far the router had to go to find a road, in metres, once this stop has routed.
*
* null means either not routed yet or close enough that nobody would call it a gap.
*/
offRoadMeters?: number | null
}
export interface RoadtripDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
stops: RoadtripStop[]
/** Leg i connects stops[i] → stops[i+1]. Shorter than `stops` by one once every leg resolved. */
legs: (RouteSegment | undefined)[]
/** Arrival and departure per stop, walked forward from the day's first pinned time. */
schedule: Schedule
/**
* Halts a routing plugin placed on each leg, indexed alongside `legs`.
*
* Read-only and never written back: a plugin halt that became a via would go out as a
* waypoint on the next run, and the plugin would then optimise around its own charging
* stop. Kept out of `RouteSegment` because that type is read by half the map.
*/
legVias: RouteVia[][]
/**
* The roads actually driven that day, as [lat, lng] — not the straight lines between
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
* Berlin the straight line runs across open country while the motorway swings north of
* it, so a corridor built from the stops finds the wrong side of the map.
*/
geometry: [number, number][]
distance: number
duration: number
/**
* Findings about the driving itself, per leg: too long behind the wheel, the tank
* running out before the next fuel stop. Separate from `schedule.warnings`, which are
* findings about a stop, because these belong to no stop and because `Schedule` is
* pinned shape-for-shape by its own tests.
*/
driveWarnings: ScheduleWarning[]
/** More driving in this day than the traveller allows, or null. */
dayWarning: DayWarning | null
}
/** A place, the road it is driven to from, and how far apart the two are. */
export interface AccessSpur {
line: [[number, number], [number, number]]
meters: number
/** So the rail and the map can point at the same one. */
stopKey: string
}
/** A day the rail does not draw a drive for, but that a stop can still be moved onto. */
export interface QuietDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
/** Usually none or one — two would have made it a drive. */
stops: RoadtripStop[]
}
export interface RoadtripRoutes {
days: RoadtripDay[]
/**
* Days with fewer than two stops, in trip order.
*
* They carry no drive, so they are not part of `days` and nothing is routed for them —
* but a stop has to be able to move onto one, and a day that is not on screen cannot be
* dropped on. Listed separately rather than folded into `days` so the totals and the
* routing keep meaning exactly what they meant before.
*/
quietDays: QuietDay[]
/** One polyline per routed leg, in trip order — what the map draws in road trip mode. */
lines: [number, number][][]
/**
* The gap between a place and the road it was routed from, as a two-point line.
*
* Drawn dashed, the way Google draws the walk from the kerb to the door, so a stop well
* off the road reads as "the drive ends here and the rest is not driving" instead of
* looking like a route that cuts across open country.
*/
accessLines: AccessSpur[]
/** Every plugin halt of the trip, flat, which is the shape the map draws. */
vias: RouteVia[]
/** The routed legs themselves, so the map can label each connector. */
segments: RouteSegment[]
totalDistance: number
totalDuration: number
totalStops: number
/** True while legs are still coming in — the numbers below are a partial sum until then. */
loading: boolean
}
/**
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
* request per second; a road trip asks for every leg of every day, so without spacing
* the first handful answer and the rest come back 429.
*/
const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator cache, not the network. */
const CACHE_HIT_MS = 60
/** How often a leg that failed (usually a rate limit) is tried again, and how long after. */
const RETRY_DELAYS_MS = [1500, 4000]
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal.aborted) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal.removeEventListener('abort', done)
resolve()
}
signal.addEventListener('abort', done, { once: true })
})
/**
* A leg that routed: the numbers for the rail plus the geometry the map draws.
*
* The two optional fields are filled by their own features and are declared here rather
* than added later, because this shape is written in one place and read in another and
* three separate changes to the same two lines is three merge conflicts.
*/
interface RoutedLeg {
seg: RouteSegment
line: [number, number][]
/**
* Where the router actually put each end of this leg, and how far that is from the
* place we asked for. OSRM snaps every waypoint to the nearest road with no distance
* limit and reports both in every response; a place set well off the road otherwise
* looks like it is on the route when the drive really starts hundreds of metres away.
*/
snapped?: { from: SnappedPoint | null; to: SnappedPoint | null }
/** Halts a routing plugin placed on this leg, in the order they are driven through. */
vias: RouteVia[]
}
/** A waypoint as the router resolved it, beside the coordinate that was asked for. */
export interface SnappedPoint {
lat: number
lng: number
/** Straight-line metres from the requested coordinate to the road it snapped to. */
offRoadMeters: number
}
/**
* What makes a stop the same stop as far as routing is concerned. `planKey` is built from
* these, and so are the keys the routed legs are filed under, so the two can never drift
* apart.
*
* Deliberately not the assignment id. A stop added mid-day is written optimistically with
* a temporary negative id (`assignmentsSlice`) and swapped for the real one once the
* server answers, without its coordinates changing — so `planKey` stays identical, the
* effect does not run again, and a leg filed under the id would sit under a dead one
* forever. A missing leg breaks the schedule's chain (`computeSchedule` gives up its
* cursor), which would silently blank every arrival time after the new stop.
*/
const stopKey = (s: RoadtripStop): string =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
const asStop = (a: Assignment): RoadtripStop | null => {
const p = a.place
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
return {
assignmentId: a.id,
placeId: a.place_id,
name: p.name,
lat: p.lat,
lng: p.lng,
time: a.assignment_time ?? p.place_time ?? null,
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
legMode: a.leg_transport_mode ?? null,
incomingLegMode: a.incoming_leg_transport_mode ?? null,
stopType: p.stop_type ?? null,
}
}
/**
* Distance and driving time for every leg of every day of the trip.
*
* The day plan sidebar routes one day at a time because that is all it shows; a road
* trip is the whole chain, which is exactly what #435 asks for.
*
* A day is one request, not one per leg: the router already returns a leg for every
* consecutive pair of the waypoints it is handed. Requests go out one after another
* with a gap, because the public routing hosts TREK ships with allow about one per
* second — but only the ones that actually reach the network are paced, so returning
* to this view costs nothing.
*/
export function useRoadtripRoutes(
tripId: number | string | null,
days: Day[],
assignments: AssignmentsMap,
/** Mode for legs that neither the stop nor the day pins down. */
fallbackProfile: string = 'driving',
/**
* Points the drive is made to pass through, per day (#1797). They join the routing
* request between the stops they follow, so the router draws the road the traveller
* chose rather than the one it prefers.
*/
viasByDay: Record<number, RoadtripVia[]> = {},
): RoadtripRoutes {
const routeProfile = fallbackProfile || 'driving'
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [legsByDay, setLegsByDay] = useState<Record<number, Record<string, RoutedLeg>>>({})
/**
* Where the router put each stop, by day and stop key.
*
* Kept beside the legs rather than inside them because it belongs to a stop and a leg
* has two of them: the arrival end of one leg is the departure end of the next, and
* filing it twice would draw the spur twice.
*/
const [snapByDay, setSnapByDay] = useState<Record<number, Record<string, SnappedWaypoint>>>({})
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
// same thing, so it is folded here rather than guarded at every reading.
const limits = useMemo<DriveLimits>(
() => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }),
[legMinutes, dayMinutes, rangeKm],
)
const plan = useMemo<RoadtripDay[]>(() => {
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(d => {
const stops = (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(asStop)
.filter((s): s is RoadtripStop => s !== null)
return {
dayId: d.id,
dayNumber: d.day_number ?? 0,
date: d.date ?? null,
title: d.title ?? null,
stops,
legs: [],
legVias: [],
driveWarnings: [],
dayWarning: null,
schedule: { entries: [], warnings: [] },
geometry: [],
distance: 0,
duration: 0,
}
})
.filter(d => d.stops.length > 1)
}, [days, assignments])
const quietDays = useMemo<QuietDay[]>(() => {
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(d => ({
dayId: d.id,
dayNumber: d.day_number ?? 0,
date: d.date ?? null,
title: d.title ?? null,
stops: (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(asStop)
.filter((s): s is RoadtripStop => s !== null),
}))
.filter(d => d.stops.length < 2)
}, [days, assignments])
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round.
const viaKey = useMemo(
() => Object.entries(viasByDay)
.map(([dayId, vias]) => `${dayId}:${vias.map(v => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`)
// Only the order has to be stable — this is a cache key, not a list anybody reads
// — but it has to be stable on purpose rather than by default.
.sort((a, b) => a.localeCompare(b))
.join(';'),
[viasByDay],
)
const planKey = useMemo(
() => `${plan.map(d => `${d.dayId}:${d.stops.map(stopKey).join('|')}`).join(';')}#${viaKey}`,
[plan, viaKey],
)
useEffect(() => {
abortRef.current?.abort()
if (!plan.length) {
setLegsByDay({})
setSnapByDay({})
setLoading(false)
return
}
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
const dayDefault = (dayId: number): string =>
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
const collected: Record<number, Record<string, RoutedLeg>> = {}
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
const tasks: (() => Promise<void>)[] = []
for (const day of plan) {
const dayLegs: Record<string, RoutedLeg> = {}
collected[day.dayId] = dayLegs
const dfMode = dayDefault(day.dayId)
const runs = splitIntoRuns(day.stops, (from, to) =>
resolveLegMode(
{ isPlace: true, leg_transport_mode: from.legMode },
{ isPlace: true, incoming_leg_transport_mode: to.incomingLegMode },
dfMode,
))
const dayVias = viasByDay[day.dayId] ?? []
for (const { stops: run, mode } of runs) {
tasks.push(async () => {
// Waypoints are the stops with this day's vias threaded in between them, so the
// router draws the road the traveller picked. `stopAt` remembers which waypoint
// each stop became, because the answer has a leg per waypoint PAIR and the rail
// wants one leg per stop pair.
const waypoints: { lat: number; lng: number }[] = []
const stopAt: number[] = []
run.forEach((stop, i) => {
stopAt.push(waypoints.length)
waypoints.push({ lat: stop.lat, lng: stop.lng })
if (i === run.length - 1) return
const dayIndex = day.stops.indexOf(stop)
dayVias
.filter(v => v.after_order_index === dayIndex)
.sort((a, b) => a.sequence - b.sequence)
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
})
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
if (controller.signal.aborted) return
try {
const r = await calculateRouteWithLegs(
waypoints,
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
)
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
// vias threaded in between are skipped: a via is a shape handle, not a
// destination, and a dashed spur hanging off one reads as a fault.
if (r.snapped) {
const daySnaps = (collectedSnaps[day.dayId] ??= {})
run.forEach((stop, i) => {
const s = r.snapped?.[stopAt[i]]
if (s) daySnaps[stopKey(stop)] = s
})
}
// Where a plugin's halts sit along this run, measured once. Only the ones
// that say something get projected — a bare coordinate has nothing to show
// in the rail, and projection is not free.
const spine = r.coordinates.map(([la, ln]) => ({ lat: la, lng: ln }))
const viaAlong = (r.vias ?? [])
.filter(v => v.label || v.dwellSeconds != null)
.map(v => ({ via: v, alongMeters: (projectOntoRoute({ lat: v.lat, lng: v.lng }, spine)?.alongKm ?? 0) * 1000 }))
// Fold the router's per-waypoint legs back onto the stop pairs: a stop pair
// with a via between it comes back as two legs, and the rail shows one.
// A running metre count comes with it, so each halt lands on the leg it is
// actually driven on rather than on the nearest stop, which two towns close
// together would get wrong.
const legEndMeters: number[] = []
const legKeys: string[] = []
let travelled = 0
for (let i = 0; i < run.length - 1; i++) {
const from = run[i]
const to = run[i + 1]
if (!from || !to) continue
const parts = r.legs.slice(stopAt[i], stopAt[i + 1])
if (!parts.length) continue
const merged = parts.length === 1 ? parts[0] : {
...parts[0],
distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0),
duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0),
}
travelled += merged.distance ?? 0
legEndMeters.push(travelled)
const key = legKey(from, to)
legKeys.push(key)
dayLegs[key] = { seg: { ...merged, mode }, line: i === 0 ? r.coordinates : [], vias: [] }
}
for (const { via, alongMeters } of viaAlong) {
const idx = legIndexForAlong(legEndMeters, alongMeters)
if (idx >= 0) dayLegs[legKeys[idx]].vias.push(via)
}
return
} catch (err) {
// Almost always a rate limit on the shared routing host. Back off and try
// again; a run that still won't route (island hop, dead router) simply stays
// blank, and the totals say so by being partial rather than wrong.
const delay = RETRY_DELAYS_MS[attempt]
if (delay === undefined) return
// When the host says how long to wait, waiting less is just a second refusal.
const asked = err instanceof RoutingRefusedError && err.isRateLimit ? err.retryAfterMs : null
await sleep(Math.max(delay, asked ?? 0), controller.signal)
}
}
})
}
}
// One at a time, spaced out. The day sidebar can afford a small pool because it
// routes a single day; a road trip is every leg of every day at once, and the
// public routing hosts answer that with 429 after the first handful. Results are
// published as they land so the rail fills in instead of sitting empty.
void (async () => {
for (let i = 0; i < tasks.length; i++) {
if (controller.signal.aborted) return
const startedAt = performance.now()
await tasks[i]()
if (controller.signal.aborted) return
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
// Only pace what actually went out. RouteCalculator answers a repeat from its
// cache in well under a millisecond, and switching back into road trip mode is
// all repeats — waiting a second between those made a warm view feel broken.
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, controller.signal)
}
if (!controller.signal.aborted) setLoading(false)
})()
return () => controller.abort()
// planKey stands in for `plan`: same geometry, same legs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [planKey, routeProfile, distanceUnit, tripId])
return useMemo(() => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
const accessLines: RoadtripRoutes['accessLines'] = []
// A loop rather than a map, because the range budget outlives a single day: a tank
// does not empty overnight, so what is left at the end of one day is what the next
// one starts with. `plan` is already in day order.
const out: RoadtripDay[] = []
let carryKm: number | null = 0
for (const day of plan) {
const dayLegs = legsByDay[day.dayId] ?? {}
const daySnaps = snapByDay[day.dayId] ?? {}
const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])])
for (const leg of routed) {
if (!leg) continue
if (leg.line.length > 1) lines.push(leg.line)
segments.push(leg.seg)
}
// The run polylines end to end: leg 0 of a run carries its whole geometry, the
// rest carry none, so concatenating what is there gives the day as driven.
const geometry = routed.flatMap(l => l?.line ?? [])
const legs = routed.map(l => l?.seg)
const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
const schedule = computeSchedule(
day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
legs.map(l => l?.duration),
)
const legVias = routed.map(l => l?.vias ?? [])
const stops = day.stops.map(s => {
const snap = daySnaps[stopKey(s)]
const line = spurFor(snap)
if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
return { ...s, offRoadMeters: line ? snap.meters : null }
})
const drive = deriveDriveWarnings(
legs,
// One longer than the legs: the last stop of the day counts too, because filling
// up on arrival is what makes the next morning start full.
stops.map(s => refuelsRange(s.stopType)),
limits,
carryKm,
)
carryKm = drive.carryKm
out.push({
...day, stops, legs, legVias, schedule, geometry, distance, duration,
driveWarnings: drive.warnings,
dayWarning: drive.day,
})
}
return {
days: out,
lines,
segments,
accessLines,
vias: out.flatMap(d => d.legVias.flat()),
totalDistance: out.reduce((s, d) => s + d.distance, 0),
totalDuration: out.reduce((s, d) => s + d.duration, 0),
// Service stops are not stops in this sense — a charger on the way is part of the
// drive, and counting it here would make the head disagree with the day cards.
totalStops: out.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
quietDays,
loading,
}
}, [plan, legsByDay, snapByDay, loading, quietDays, limits])
}
@@ -1,110 +0,0 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { roadtripApi } = vi.hoisted(() => ({
roadtripApi: {
listVias: vi.fn(),
addVia: vi.fn(),
moveVia: vi.fn(),
removeVia: vi.fn(),
},
}))
vi.mock('../../api/client', () => ({ roadtripApi }))
import { useRoadtripVias } from './useRoadtripVias'
const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
})
beforeEach(() => {
roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
roadtripApi.removeVia.mockReset().mockResolvedValue({ success: true })
})
describe('useRoadtripVias', () => {
it('FE-ROADTRIP-VIAS-001: asks once for the whole trip, not once per day', async () => {
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(Object.keys(result.current.byDay)).toHaveLength(2))
expect(roadtripApi.listVias).toHaveBeenCalledTimes(1)
expect(result.current.byDay[1].map(v => v.id)).toEqual([1])
})
it('FE-ROADTRIP-VIAS-002: with road trip mode off nothing is fetched', async () => {
const { result } = renderHook(() => useRoadtripVias(7, false))
await waitFor(() => expect(result.current.byDay).toEqual({}))
expect(roadtripApi.listVias).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-VIAS-003: an addon that is switched off means no vias, not an error', async () => {
// Shaped like the rejection axios actually produces, so the branch that
// reads the status is the one under test.
roadtripApi.listVias.mockRejectedValue({ response: { status: 404 } })
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
expect(result.current.byDay).toEqual({})
expect(result.current.stale).toBe(false)
})
it('FE-ROADTRIP-VIAS-007: a read that fails for another reason keeps the vias it has', async () => {
// This read runs after every write, and the via list feeds the routing key.
// Emptying on a 502, a dropped connection or a tab that just went offline
// re-routed the whole trip along the roads the traveller had steered away
// from — with no toast, nothing retrying, and no way to tell it apart from
// having deleted them.
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
roadtripApi.listVias.mockRejectedValue({ response: { status: 502 } })
await act(async () => { await result.current.move(1, 1, 54, 11) })
expect(result.current.byDay[1]).toHaveLength(1)
expect(result.current.stale).toBe(true)
// And a read that works again clears the doubt.
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
await act(async () => { await result.current.move(1, 1, 54, 11) })
expect(result.current.stale).toBe(false)
})
it('FE-ROADTRIP-VIAS-004: adding one re-reads the list rather than guessing its id', async () => {
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledTimes(1))
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 5, day_id: 3 })] })
await act(async () => { await result.current.add(3, 1, 52, 11) })
expect(roadtripApi.addVia).toHaveBeenCalledWith(7, 3, { after_order_index: 1, lat: 52, lng: 11 })
// The server assigns id and sequence; patching the list by hand would be a second
// source of truth for the sake of one round trip.
await waitFor(() => expect(result.current.byDay[3]?.[0].id).toBe(5))
})
it('FE-ROADTRIP-VIAS-005: moving and removing go to the day they belong to', async () => {
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
await act(async () => { await result.current.move(2, 9, 51, 12) })
expect(roadtripApi.moveVia).toHaveBeenCalledWith(7, 2, 9, { lat: 51, lng: 12 })
await act(async () => { await result.current.remove(2, 9) })
expect(roadtripApi.removeVia).toHaveBeenCalledWith(7, 2, 9)
})
it('FE-ROADTRIP-VIAS-006: without a trip there is nothing to write to', async () => {
const { result } = renderHook(() => useRoadtripVias(null, true))
await act(async () => { await result.current.add(1, 0, 53, 10) })
expect(roadtripApi.addVia).not.toHaveBeenCalled()
})
})
@@ -1,193 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { roadtripApi } from '../../api/client'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { isEmptyReanchoring, type Reanchoring } from './roadtripModel'
import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared'
/**
* Via points are DELIBERATELY online-only, which the repo asks to be said out
* loud rather than left to be discovered.
*
* Everything else on this screen goes through a repo and the mutation queue:
* optimistic Dexie write, temporary negative id, idempotent replay on
* reconnect. A via cannot follow that shape as it stands, because its anchor is
* a POSITION rather than an id — `after_order_index` counts stops as the routing
* request builds them — so a queued write replayed after somebody else has
* added or removed a stop would re-pin the drive onto a leg nobody chose, and it
* would do so silently, hours later. The correct offline story needs the anchor
* to be an assignment id first, which is a change to the wire contract and to
* the migration, not to this hook.
*
* Until then the honest behaviour is to say so: `editable` is false with no
* network, so the map stops offering a drag it cannot keep, instead of failing
* one write at a time and losing the edit.
*/
export interface RoadtripVias {
/** Every via of the trip, keyed by day. */
byDay: Record<number, RoadtripVia[]>
/**
* Which imported track each day was fitted to, keyed by day.
*
* The vias are what make the day follow it; this is what lets the day say so. Empty for
* a day shaped by hand, and gone by itself when the track is deleted — the row cascades
* with the place, so nothing here can name a line that no longer exists.
*/
trackByDay: Record<number, RoadtripDayTrack>
/**
* True when the last read failed for a reason other than "there is nothing
* here", so what is drawn may be older than what the server holds.
*
* The read runs after every write, and the via list feeds the routing key, so
* emptying on a transient failure re-routed the whole trip along the roads the
* traveller had steered away from — indistinguishable from having deleted
* them. The list is kept and the staleness is said instead.
*/
stale: boolean
/**
* Whether shaping the drive is possible right now.
*
* False with no network: the domain is online-only (see the note above this
* interface), so the surfaces disable the gesture rather than accepting one
* and dropping it.
*/
editable: boolean
add: (dayId: number, afterOrderIndex: number, lat: number, lng: number) => Promise<void>
/**
* Lay a chain of vias on one day, optionally clearing the legs it fills first.
*
* One request and one reload for the whole chain. `add` per point would trigger a full
* trip re-route between each one, spaced by the routing host's rate limit, so a
* twenty-anchor track would spend half a minute drawing routes nobody asked to see.
*/
addMany: (
dayId: number,
vias: { after_order_index: number; lat: number; lng: number }[],
replaceLegs?: number[],
/** Absent leaves the day's track alone, null clears it, an object records a new one. */
track?: { place_id: number; stray_km?: number | null } | null,
) => Promise<void>
move: (dayId: number, id: number, lat: number, lng: number) => Promise<void>
remove: (dayId: number, id: number) => Promise<void>
/**
* Correct a day's anchors after its stops changed shape.
*
* Awaited by the caller before it lets the day re-route: the routing effect resolves
* `after_order_index` against whatever the stop list looks like at that moment, so a
* re-anchoring that lands afterwards is a second, visibly wrong route in between.
*/
reanchor: (dayId: number, plan: Reanchoring) => Promise<void>
}
const EMPTY: Record<number, RoadtripVia[]> = {}
const EMPTY_TRACKS: Record<number, RoadtripDayTrack> = {}
/**
* The points this trip's drives are routed through.
*
* Loaded once for the whole trip rather than per day: a road trip routes every day at
* once, and one request for the lot beats one per day against a server that has to open
* the same table each time.
*
* Writes are optimistic in the sense that the list is refreshed from the answer, not
* patched by hand — a via has a server-assigned id and sequence, and guessing them would
* be a second source of truth for the sake of one round trip.
*/
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
const [byDay, setByDay] = useState<Record<number, RoadtripVia[]>>(EMPTY)
const [trackByDay, setTrackByDay] = useState<Record<number, RoadtripDayTrack>>(EMPTY_TRACKS)
/**
* The list on screen may be older than the server's.
*
* Set when a read fails for a reason that is not "there is nothing here", so
* the rail can say the shaping it is drawing might be out of date rather than
* quietly showing a trip that has lost its detours.
*/
const [stale, setStale] = useState(false)
// Read through the app's single source of truth for connectivity, which also
// covers the offline switch a user can set with the network still up.
const { offline } = useNetworkMode()
const group = useCallback((vias: RoadtripVia[]) => {
const next: Record<number, RoadtripVia[]> = {}
for (const v of vias) (next[v.day_id] ??= []).push(v)
return next
}, [])
const reload = useCallback(async () => {
if (!tripId || !active) { setByDay(EMPTY); setTrackByDay(EMPTY_TRACKS); return }
try {
const { vias, tracks } = await roadtripApi.listVias(tripId)
setByDay(group(vias))
const byId: Record<number, RoadtripDayTrack> = {}
for (const track of tracks ?? []) byId[track.day_id] = track
setTrackByDay(byId)
setStale(false)
} catch (err) {
// An instance with the addon off answers 404 here, and a caller without
// the permission 403. Neither is worth reporting: it just means there are
// no vias to draw, and emptying is the truthful answer.
//
// Anything else is not. This read runs after every write, so a 502 from a
// proxy, a dropped connection or a tab that has just gone offline used to
// empty the map — and because the via list feeds the routing key, the whole
// trip was then re-routed along the roads the traveller had steered away
// from. Nothing said so, nothing retried, and it looked exactly like
// having deleted them. The last known list is kept instead.
const status = (err as { response?: { status?: number } } | null)?.response?.status
if (status === 404 || status === 403) {
setByDay(EMPTY)
setTrackByDay(EMPTY_TRACKS)
setStale(false)
return
}
setStale(true)
}
}, [tripId, active, group])
useEffect(() => { void reload() }, [reload])
const add = useCallback(async (dayId: number, afterOrderIndex: number, lat: number, lng: number) => {
if (!tripId) return
await roadtripApi.addVia(tripId, dayId, { after_order_index: afterOrderIndex, lat, lng })
await reload()
}, [tripId, reload])
const addMany = useCallback(async (
dayId: number,
vias: { after_order_index: number; lat: number; lng: number }[],
replaceLegs?: number[],
track?: { place_id: number; stray_km?: number | null } | null,
) => {
if (!tripId) return
// An empty chain with nothing to clear is not a write. It is a real call though, from
// a track that thinned down to nothing on a very short day, and from a track that the
// drive already followed — which still has a name to record.
if (!vias.length && !replaceLegs?.length && track === undefined) return
await roadtripApi.addVias(tripId, dayId, {
vias,
replace_legs: replaceLegs,
...(track === undefined ? {} : { track }),
})
await reload()
}, [tripId, reload])
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
if (!tripId) return
await roadtripApi.moveVia(tripId, dayId, id, { lat, lng })
await reload()
}, [tripId, reload])
const reanchor = useCallback(async (dayId: number, plan: Reanchoring) => {
if (!tripId || isEmptyReanchoring(plan)) return
await roadtripApi.reanchorVias(tripId, dayId, plan)
await reload()
}, [tripId, reload])
const remove = useCallback(async (dayId: number, id: number) => {
if (!tripId) return
await roadtripApi.removeVia(tripId, dayId, id)
await reload()
}, [tripId, reload])
return { byDay, trackByDay, stale, editable: !offline, add, addMany, move, remove, reanchor }
}
@@ -1,150 +0,0 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { calculateAlternatives, calculateRoute } = vi.hoisted(() => ({
calculateAlternatives: vi.fn(),
calculateRoute: vi.fn(),
}))
vi.mock('../Map/RouteCalculator', () => ({ calculateAlternatives, calculateRoute }))
import { useRouteAlternatives } from './useRouteAlternatives'
import type { RoadtripStop } from './useRoadtripRoutes'
import type { RoadtripVia } from '@trek/shared'
/**
* FE-ALTHOOK-001..009 — asking for other ways of driving one leg.
*
* The case that carries the feature: when the leg already carries vias, the road
* actually being driven is NOT among what the router offers for the two bare
* endpoints, because it was asked a different question. Without fetching it
* separately, opening the picker on a leg you have already reshaped shows three
* roads, none of which is the one you are on, and no way back to it.
*/
const from = { lat: 53.55, lng: 9.99 } as RoadtripStop
const to = { lat: 52.52, lng: 13.4 } as RoadtripStop
const via = (lat: number, lng: number): RoadtripVia =>
({ id: 1, day_id: 4, after_order_index: 0, sequence: 0, lat, lng })
const route = (over: Record<string, unknown> = {}) => ({
coordinates: [[53.55, 9.99], [52.52, 13.4]],
distance: 290_000,
duration: 10_800,
divergence: { lat: 53, lng: 11 },
...over,
})
beforeEach(() => {
calculateAlternatives.mockReset().mockResolvedValue([route(), route({ distance: 310_000, duration: 11_400 })])
calculateRoute.mockReset().mockResolvedValue(route({ distance: 340_000, duration: 12_600 }))
})
describe('useRouteAlternatives', () => {
it('FE-ALTHOOK-001: nothing is open until somebody asks', () => {
const { result } = renderHook(() => useRouteAlternatives())
expect(result.current.open).toBeNull()
expect(calculateAlternatives).not.toHaveBeenCalled()
})
it('FE-ALTHOOK-002: asking opens on a loading state, then fills it', async () => {
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
expect(result.current.open).toMatchObject({ dayId: 4, index: 1, loading: true, routes: [] })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.routes).toHaveLength(2)
// The router's own first answer is the direct one, which is what "no detour
// at all" means when it is chosen.
expect(result.current.open?.routes[0].direct).toBe(true)
expect(result.current.open?.routes[1].direct).toBe(false)
})
it('FE-ALTHOOK-003: a bare leg is not asked for the road it is already driving', () => {
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
expect(calculateRoute).not.toHaveBeenCalled()
})
it('FE-ALTHOOK-004: a bent leg puts the road being driven at the top', async () => {
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.routes[0]).toMatchObject({ current: true, distance: 340_000 })
expect(result.current.open?.routes).toHaveLength(3)
// Routed through the via, not between the bare ends.
expect(calculateRoute).toHaveBeenCalledWith(
[{ lat: 53.55, lng: 9.99 }, { lat: 53, lng: 11.5 }, { lat: 52.52, lng: 13.4 }],
'driving',
expect.anything(),
)
})
it('FE-ALTHOOK-005: a via that barely moves the route is one road, not two', async () => {
// Two lines are the same road if their ends and their length agree closely
// enough. Listing it twice offers a choice that is not one.
calculateRoute.mockResolvedValue(route({ distance: 290_020, duration: 10_810 }))
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11)]) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.routes).toHaveLength(2)
expect(result.current.open?.routes.some(r => r.current)).toBe(false)
})
it('FE-ALTHOOK-006: the driven road failing costs that entry, not the dialog', async () => {
calculateRoute.mockRejectedValue(new Error('osrm down'))
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.error).toBe(false)
expect(result.current.open?.routes).toHaveLength(2)
})
it('FE-ALTHOOK-007: a router that will not answer says so instead of showing nothing', async () => {
calculateAlternatives.mockRejectedValue(new Error('osrm down'))
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open).toMatchObject({ error: true, routes: [] })
})
it('FE-ALTHOOK-008: only driving, walking and cycling reach the router', () => {
const { result } = renderHook(() => useRouteAlternatives())
for (const [asked, sent] of [['walking', 'walking'], ['cycling', 'cycling'], ['transit', 'driving'], ['plugin:ferry', 'driving']]) {
calculateAlternatives.mockClear()
act(() => { result.current.ask(4, 1, from, to, asked, []) })
expect(calculateAlternatives.mock.calls[0][2], asked).toBe(sent)
}
})
it('FE-ALTHOOK-009: asking again abandons the answer still in flight', async () => {
// Two clicks in a row must not let the first answer land on top of the
// second: the picker would then describe a leg nobody is looking at.
const signals: AbortSignal[] = []
calculateAlternatives.mockImplementation((_a: unknown, _b: unknown, _p: unknown, opts: { signal: AbortSignal }) => {
signals.push(opts.signal)
return new Promise(() => {})
})
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
act(() => { result.current.ask(4, 2, from, to, 'driving', []) })
expect(signals[0].aborted).toBe(true)
expect(signals[1].aborted).toBe(false)
expect(result.current.open?.index).toBe(2)
act(() => { result.current.close() })
expect(signals[1].aborted).toBe(true)
expect(result.current.open).toBeNull()
})
})
@@ -1,112 +0,0 @@
import { useCallback, useRef, useState } from 'react'
import { calculateAlternatives, calculateRoute, type RouteAlternative } from '../Map/RouteCalculator'
import type { RoadtripStop } from './useRoadtripRoutes'
import type { RoadtripVia } from '@trek/shared'
/** One offered way of driving a leg, with what it means for this trip. */
export interface OfferedRoute extends RouteAlternative {
/** True for the road currently driven — the one the vias on this leg produce. */
current?: boolean
/** True for the router's own preference with no vias at all. */
direct?: boolean
}
/** Which leg is being reconsidered, and what was offered for it. */
export interface LegAlternatives {
dayId: number
/** Index of the leg within the day: the drive from stop `index` to `index + 1`. */
index: number
routes: OfferedRoute[]
loading: boolean
error: boolean
}
export interface RouteAlternativesState {
open: LegAlternatives | null
/** Asks the router for the ways of driving this one leg. */
ask: (dayId: number, index: number, from: RoadtripStop, to: RoadtripStop, profile: string, vias: RoadtripVia[]) => void
close: () => void
}
/** Two lines are the same road if their ends and their length agree closely enough. */
function sameRoute(a: RouteAlternative, b: RouteAlternative): boolean {
return Math.abs(a.distance - b.distance) < 50 && Math.abs(a.duration - b.duration) < 30
}
/**
* Other ways of driving one leg.
*
* One leg at a time, on demand, and never for the whole trip: OSRM answers with
* alternatives only between exactly two coordinates, so asking for a day would mean
* breaking up the single request `splitIntoRuns` bundles it into — the optimisation that
* makes a road trip appear at once rather than trickle in.
*
* When the leg already carries vias, the road actually being driven is NOT among what the
* router offers for the two bare endpoints — it was asked a different question. So it is
* fetched separately and put at the top, marked as the current one. Without that, opening
* the picker on a leg you have already reshaped shows you three roads, none of which is
* the one you are on, and no way back to it.
*/
export function useRouteAlternatives(): RouteAlternativesState {
const [open, setOpen] = useState<LegAlternatives | null>(null)
const abortRef = useRef<AbortController | null>(null)
const close = useCallback(() => {
abortRef.current?.abort()
abortRef.current = null
setOpen(null)
}, [])
const ask = useCallback((
dayId: number,
index: number,
from: RoadtripStop,
to: RoadtripStop,
profile: string,
vias: RoadtripVia[],
) => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setOpen({ dayId, index, routes: [], loading: true, error: false })
const osrmProfile: 'driving' | 'walking' | 'cycling' =
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
const ends = [{ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }]
// The router's offers for the bare leg, plus — only when the leg is already bent —
// the road that bend produces, so both are on the table together.
const offers = calculateAlternatives(ends[0], ends[1], osrmProfile, { signal: controller.signal })
const current = vias.length
? calculateRoute(
[ends[0], ...vias.map(v => ({ lat: v.lat, lng: v.lng })), ends[1]],
osrmProfile,
{ signal: controller.signal },
).then(r => ({
coordinates: r.coordinates,
distance: r.distance,
duration: r.duration,
divergence: null,
current: true,
} as OfferedRoute)).catch(() => null)
: Promise.resolve(null)
void Promise.all([offers, current])
.then(([alternatives, driven]) => {
if (controller.signal.aborted) return
const routes: OfferedRoute[] = alternatives.map((r, i) => ({ ...r, direct: i === 0 }))
// Only prepend the driven road when it really is a different one; with a via that
// barely moves the route, the two are the same road and one entry is honest.
if (driven && !routes.some(r => sameRoute(r, driven))) routes.unshift(driven)
setOpen({ dayId, index, routes, loading: false, error: false })
})
.catch(() => {
if (controller.signal.aborted) return
// A router that will not answer is not worth a dialog; the leg keeps the route it
// already has and the panel says so.
setOpen({ dayId, index, routes: [], loading: false, error: true })
})
}, [])
return { open, ask, close }
}
@@ -104,47 +104,6 @@ describe('CustomSelect', () => {
expect(Number.parseInt(list.style.maxHeight, 10)).toBeGreaterThan(0);
});
// A select squeezed into a narrow flex row still has to offer readable options: the
// trigger shortens to share its row with the control beside it, the menu does not.
it('FE-COMP-SELECT-011: the menu is as wide as its trigger by default', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
await user.click(screen.getByRole('button'));
const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
expect(panel.style.width).not.toBe('max-content');
expect(panel.style.maxWidth).toBe('');
});
it('FE-COMP-SELECT-012: menuFit content grows the menu to its options, bounded twice', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />);
await user.click(screen.getByRole('button'));
const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
expect(panel.style.width).toBe('max-content');
// Never narrower than the trigger, never wider than what the window still has.
expect(panel.style.minWidth).not.toBe('');
expect(Number.parseInt(panel.style.maxWidth, 10)).toBeGreaterThan(0);
});
it('FE-COMP-SELECT-013: a content-fitted menu stops at the edge of its panel', async () => {
const user = userEvent.setup();
render(
<div data-testid="panel">
<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />
</div>,
);
// jsdom measures everything as zero, so the panel says how wide it is.
const panel = screen.getByTestId('panel');
panel.getBoundingClientRect = () => ({ left: 0, right: 180, width: 180, top: 0, bottom: 32, height: 32, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
await user.click(screen.getByRole('button'));
const menu = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
expect(menu.style.maxWidth).toBe('180px');
});
it('FE-COMP-SELECT-008: disabled state prevents the dropdown from opening', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} disabled={true} placeholder="Pick" />);
+3 -42
View File
@@ -24,33 +24,6 @@ interface CustomSelectProps {
style?: React.CSSProperties
size?: 'sm' | 'md'
disabled?: boolean
/**
* How wide the open menu is.
*
* 'anchor' (the default) keeps it exactly as wide as the trigger, which is what a
* select normally wants. 'content' lets it grow to its longest option instead, for the
* cases where the trigger is a narrow flex item and the options are place names: there
* the trigger has to shorten to share its row, and a menu that shortened with it would
* offer a list of identical prefixes.
*/
menuFit?: 'anchor' | 'content'
}
/** How wide a content-fitted menu may get before it stops reading as a menu. */
const MENU_MAX_WIDTH = 420
/**
* How much room a content-fitted menu has, measured from the trigger's left edge.
*
* Bounded by the trigger's own container first: a select sits inside a panel, and a menu
* that grew past that panel's edge would hang over whatever is beside it — here, the map.
* The window is the second bound, for a container that reaches the edge of the screen.
*/
function menuRoom(anchor: HTMLElement | null, left: number): number {
const toWindowEdge = typeof window === 'undefined' ? MENU_MAX_WIDTH : window.innerWidth - left - 8
const box = anchor?.parentElement?.getBoundingClientRect()
const toPanelEdge = box && box.width > 0 ? box.right - left : Number.POSITIVE_INFINITY
return Math.min(MENU_MAX_WIDTH, toWindowEdge, toPanelEdge)
}
export default function CustomSelect({
@@ -62,7 +35,6 @@ export default function CustomSelect({
style = {},
size = 'md',
disabled = false,
menuFit = 'anchor',
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -159,20 +131,9 @@ export default function CustomSelect({
<div ref={dropRef} style={{
position: 'fixed',
...(anchored
? {
...(anchored.flipped ? { bottom: anchored.bottom } : { top: anchored.top }),
left: anchored.left,
// Never narrower than the trigger, and never past the right-hand edge of
// the window. The cap is read at render time rather than measured, and
// that is enough: every reason the anchor moved already re-renders this.
...(menuFit === 'content'
? {
minWidth: anchored.width,
width: 'max-content',
maxWidth: Math.max(anchored.width, menuRoom(ref.current, anchored.left)),
}
: { width: anchored.width }),
}
? anchored.flipped
? { bottom: anchored.bottom, left: anchored.left, width: anchored.width }
: { top: anchored.top, left: anchored.left, width: anchored.width }
: { top: 0, left: 0, width: 200 }),
zIndex: 99999,
background: 'var(--bg-card)',
+1 -10
View File
@@ -22,7 +22,6 @@ export default function EmptyState({
size = 104,
surface = 'var(--bg-card)',
layout = 'stack',
compact = false,
className = '',
action,
}: {
@@ -32,14 +31,6 @@ export default function EmptyState({
size?: number
surface?: string
layout?: 'stack' | 'row'
/**
* Quieter type for a state inside a narrow column rather than on a page.
*
* The sidebar's empty state sits under its own controls, so a line at the page size
* competes with them for the eye; at the caption tier it reads as what it is, a note
* about why the list below is blank.
*/
compact?: boolean
className?: string
/** Optional call to action under the title, for states that have an obvious next step. */
action?: ReactNode
@@ -53,7 +44,7 @@ export default function EmptyState({
style={{ '--m-ink': 'var(--text-primary)', '--m-bg': surface } as CSSProperties}
>
<MDancingTrek scene={scene} mood={mood} size={size} />
<p className={compact ? 'text-caption text-content-muted' : 'text-[15px] font-semibold text-content-secondary'}>{title}</p>
<p className="text-[15px] font-semibold text-content-secondary">{title}</p>
{action}
</div>
)
File diff suppressed because one or more lines are too long
-49
View File
@@ -68,14 +68,6 @@ export interface SyncMeta {
tilesBbox: [number, number, number, number] | null;
/** Non-photo files available offline for this trip after the last sync. */
filesCachedCount: number;
/**
* The rounded bbox the cached area places were fetched for. Compared rather
* than a timestamp: adding a place inside the area the trip already covers
* should not re-download it, and moving the trip should.
*
* Optional so a row written before this landed still reads.
*/
areaPlacesKey?: string;
}
export interface BlobCacheEntry {
@@ -94,33 +86,6 @@ export interface BlobCacheEntry {
cachedAt: number;
}
/**
* A place from the TREK Places index, cached for the area of one trip.
*
* NOT a trip place — those are in `places` and belong to the user. These are
* candidates: the shops, restaurants and stations that happen to be near where
* the trip goes, taken once so search still answers on a plane. They are
* disposable and are re-fetched whenever the trip's area changes.
*
* The key is the GERS id, which is stable across index rebuilds; `tripId` is
* indexed so the whole set can be dropped with the trip.
*/
export interface CachedAreaPlace {
/** GERS id, without the `gers:` prefix the server puts on `osm_id`. */
gers: string;
tripId: number;
name: string;
/** Lowercased, diacritics folded — what an offline search actually matches on. */
searchName: string;
address: string;
lat: number | null;
lng: number | null;
category: string | null;
website: string | null;
phone: string | null;
cachedAt: number;
}
/** An uploaded booking-import source file, kept so the review flow can attach it to the
* created bookings even after a page reload during the (background) parse. Keyed by job. */
export interface ImportSourceFile {
@@ -176,7 +141,6 @@ class TrekOfflineDb extends Dexie {
syncMeta!: Table<SyncMeta, number>;
blobCache!: Table<BlobCacheEntry, string>;
importFiles!: Table<ImportSourceFile, [string, string]>;
areaPlaces!: Table<CachedAreaPlace, string>;
constructor(name: string = ANON_DB_NAME) {
super(name);
@@ -217,13 +181,6 @@ class TrekOfflineDb extends Dexie {
this.version(4).stores({
importFiles: '[jobId+fileName], jobId, createdAt',
});
// v5: places from the TREK Places index for the trip's area, so search
// answers offline. `searchName` is indexed because that is what an offline
// query filters on; `tripId` so the set drops with its trip.
this.version(5).stores({
areaPlaces: 'gers, tripId, searchName',
});
}
}
@@ -447,7 +404,6 @@ export async function clearTripData(tripId: number): Promise<void> {
offlineDb.mutationQueue,
offlineDb.syncMeta,
offlineDb.blobCache,
offlineDb.areaPlaces,
],
async () => {
await offlineDb.days.where('trip_id').equals(tripId).delete();
@@ -463,11 +419,6 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.mutationQueue.where('tripId').equals(tripId).and(m => m.status === 'failed').delete();
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
// The cached places around this trip's area go with it. They are searched
// across every trip, so leaving them behind kept a switched-off trip
// answering offline searches — and nothing else ever deleted them, so they
// accumulated for the life of the install.
await offlineDb.areaPlaces.where('tripId').equals(tripId).delete();
},
);
// Remove the trip row itself outside the transaction since it's a separate table
-83
View File
@@ -1,83 +0,0 @@
// FE-HOOK-LOCBIAS-001 to FE-HOOK-LOCBIAS-008
import { renderHook } from '@testing-library/react';
import { useTripStore } from '../store/tripStore';
import { boxFromCoords, pointFromBox, radiusKm, useLocationBias } from './useLocationBias';
// Coordinates of real places, because the whole point of the hint is distance
// and a made-up grid would not show when a box stops being useful.
const KYOTO_A = { id: 1, lat: 35.0116, lng: 135.7681 }; // Kyoto Bahnhof
const KYOTO_B = { id: 2, lat: 34.9949, lng: 135.7850 }; // Kiyomizu-dera, ~2 km
const TOKIO = { id: 3, lat: 35.6762, lng: 139.6503 }; // ~370 km entfernt
function seed(state: Record<string, unknown>) {
useTripStore.setState({ places: [], assignments: {}, selectedDayId: null, ...state } as never);
}
afterEach(() => seed({}));
describe('boxFromCoords', () => {
it('ignoriert Eintraege ohne brauchbare Koordinaten', () => {
const box = boxFromCoords([KYOTO_A, { lat: null, lng: null }, { lat: 'x', lng: 'y' }]);
expect(box).toEqual({ low: { lat: KYOTO_A.lat, lng: KYOTO_A.lng }, high: { lat: KYOTO_A.lat, lng: KYOTO_A.lng } });
});
it('gibt nichts zurueck, wenn keine Koordinate brauchbar ist', () => {
expect(boxFromCoords([{ lat: null, lng: null }])).toBeUndefined();
expect(boxFromCoords([])).toBeUndefined();
});
});
describe('pointFromBox', () => {
it('legt den Punkt in die Mitte und deckt die Box mit dem Radius ab', () => {
const punkt = pointFromBox(boxFromCoords([KYOTO_A, KYOTO_B]));
expect(punkt!.lat).toBeCloseTo((KYOTO_A.lat + KYOTO_B.lat) / 2, 6);
expect(punkt!.lng).toBeCloseTo((KYOTO_A.lng + KYOTO_B.lng) / 2, 6);
// gut 2 km Diagonale, also rund 1 km Radius — angehoben auf die Untergrenze
expect(punkt!.radius).toBe(5000);
});
it('haelt eine Untergrenze ein, damit ein einzelner Ort die Nachbarschaft meint', () => {
// Ein Punkt ohne Ausdehnung wuerde sonst mit Radius 0 auf die Tuerschwelle zeigen.
expect(pointFromBox(boxFromCoords([KYOTO_A]))!.radius).toBe(5000);
});
});
describe('useLocationBias', () => {
it('gibt ohne Orte keinen Hinweis', () => {
seed({});
expect(renderHook(() => useLocationBias()).result.current).toEqual({});
});
it('nimmt die Orte des geoeffneten Tages, nicht die der ganzen Reise', () => {
// Die Reise reicht von Kyoto bis Tokio; der offene Tag spielt in Kyoto.
seed({
places: [KYOTO_A, KYOTO_B, TOKIO],
assignments: { '7': [{ place_id: 1 }, { place_id: 2 }] },
selectedDayId: 7,
});
const { box } = renderHook(() => useLocationBias()).result.current;
expect(box).toBeDefined();
// Tokio darf nicht in der Box liegen, sonst zeigt der Hinweis ins Leere.
expect(box!.high.lng).toBeLessThan(TOKIO.lng);
expect(radiusKm(box!)).toBeLessThan(5);
});
it('faellt auf die Reise zurueck, solange sie in eine Region passt', () => {
seed({ places: [KYOTO_A, KYOTO_B] });
const { box, point } = renderHook(() => useLocationBias()).result.current;
expect(box).toBeDefined();
expect(point!.lat).toBeCloseTo((KYOTO_A.lat + KYOTO_B.lat) / 2, 6);
});
it('verwirft einen Hinweis, der ueber eine ganze Rundreise spannt', () => {
// Gemessen: eine Box ueber Kyoto UND Tokio kostet mehr Treffer auf Platz 1
// als sie einbringt. Kein Hinweis ist dann die bessere Antwort.
seed({ places: [KYOTO_A, TOKIO] });
expect(renderHook(() => useLocationBias()).result.current).toEqual({});
});
it('verwirft die zu weite Reise-Box auch dann, wenn ein Tag gewaehlt aber leer ist', () => {
seed({ places: [KYOTO_A, TOKIO], assignments: { '7': [] }, selectedDayId: 7 });
expect(renderHook(() => useLocationBias()).result.current).toEqual({});
});
});
-129
View File
@@ -1,129 +0,0 @@
import { useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
/**
* Where the user is planning right now, as a hint for the place search.
*
* Without it the search cannot tell which of a thousand identically named
* places is meant: "Hase-dera" returns the temple in Nara rather than the one
* in Kamakura, and a common single word is refused by the index as too
* expensive to answer, which drops the search back to Nominatim alone.
*
* The hint has to be NARROW, and that is the part that is easy to get wrong.
* Measured over 126 places of a real trip through Japan, searched against a
* live instance:
*
* no hint at all 54.8% found, 38.1% at rank 1
* box around the WHOLE trip 57.1% found, 17.5% at rank 1
* box around the nearest few places 70.6% found, 34.9% at rank 1
*
* A box spanning a whole round trip has its centre somewhere between the
* cities, points at nothing, and costs more rank-1 hits than it gains. It is
* worse than no hint. So the day being planned comes first, and a hint that
* ends up wider than a metropolitan area is dropped rather than sent.
*/
export interface LocationBiasBox {
low: { lat: number; lng: number }
high: { lat: number; lng: number }
}
export interface LocationBiasPoint {
lat: number
lng: number
radius?: number
}
/**
* Widest hint still worth sending, as a radius. Roughly a metropolitan area:
* far enough to cover a city and its surroundings, narrow enough that its
* centre still means something. The old value here was 500km of diagonal,
* which is how the whole-trip box slipped through.
*/
const MAX_RADIUS_KM = 60
const KM_PER_DEGREE = 111
interface Coord { lat?: number | string | null; lng?: number | string | null }
export function boxFromCoords(coords: Coord[]): LocationBiasBox | undefined {
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
for (const p of coords) {
// Number(null) is 0, and 0/0 is a real coordinate in the Gulf of Guinea.
// A place without coordinates would otherwise stretch the box across half
// the planet and the hint would be dropped for being too wide.
if (p.lat == null || p.lng == null || p.lat === '' || p.lng === '') continue
const lat = Number(p.lat), lng = Number(p.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
if (lng < minLng) minLng = lng
if (lng > maxLng) maxLng = lng
}
if (!Number.isFinite(minLat)) return undefined
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
}
/** Half the diagonal of a box, in kilometres — how far the hint reaches. */
export function radiusKm(box: LocationBiasBox): number {
const lat = (box.low.lat + box.high.lat) / 2
const dLat = box.high.lat - box.low.lat
const dLng = box.high.lng - box.low.lng
return Math.sqrt(
(dLat * KM_PER_DEGREE) ** 2 + (dLng * KM_PER_DEGREE * Math.cos(lat * (Math.PI / 180))) ** 2,
) / 2
}
/**
* The centre of a box, with the radius that covers it.
*
* Autocomplete takes a box, search takes a point — same hint, two shapes,
* because the two upstream APIs were built years apart. The radius has a floor
* so a day whose places sit on one street still biases toward the
* neighbourhood rather than the doorstep.
*/
export function pointFromBox(box: LocationBiasBox | undefined): LocationBiasPoint | undefined {
if (!box) return undefined
return {
lat: (box.low.lat + box.high.lat) / 2,
lng: (box.low.lng + box.high.lng) / 2,
// Google caps a Text Search bias circle at 50 km and rejects a wider one
// outright, so the box for a trip spread over more than that is clamped
// rather than sent and refused. A bias is a hint about where to look; the
// hint is no worse for being drawn at the largest radius the API accepts.
radius: Math.min(50_000, Math.round(Math.max(radiusKm(box), 5) * 1000)),
}
}
/**
* The hint for the place search, in both shapes the two endpoints want.
*
* Preference order, narrowest first:
* 1. the places of the day currently open — this is the area being planned
* 2. the trip's places, but only if they still fit inside one region
* Anything wider is dropped: no hint beats a hint pointing at open country.
*/
export function useLocationBias(): { box?: LocationBiasBox; point?: LocationBiasPoint } {
const places = useTripStore((s) => s.places)
const assignments = useTripStore((s) => s.assignments)
const selectedDayId = useTripStore((s) => s.selectedDayId)
return useMemo(() => {
const alle = places || []
// The day being planned. Places reach a day through assignments, so the
// ids come from there and the coordinates from the place pool.
let quelle: Coord[] = []
if (selectedDayId != null) {
const desTages = new Set(
(assignments?.[String(selectedDayId)] || []).map((a) => a.place_id),
)
if (desTages.size > 0) quelle = alle.filter((p) => desTages.has(Number(p.id)))
}
if (quelle.length === 0) quelle = alle
const box = boxFromCoords(quelle)
if (!box || radiusKm(box) > MAX_RADIUS_KM) return {}
return { box, point: pointFromBox(box) }
}, [places, assignments, selectedDayId])
}
+6 -11
View File
@@ -21,9 +21,9 @@ const ROAD_PROFILE: Record<string, 'driving' | 'cycling'> = {
bicycle: 'cycling',
}
// Beyond this straight-line distance a single leg of a car/taxi/bike (or even coach)
// booking is almost always a data quirk or an inter-continental hop the road router
// can't resolve — keep the straight line and don't hammer the public OSRM demo.
// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
// almost always a data quirk or an inter-continental hop the road router can't
// resolve — keep the straight line and don't hammer the public OSRM demo.
// Shared with the day-route builder, which draws the same conclusion about a
// booking endpoint that landed next to a local stop (#2133).
const MAX_ROUTE_KM = MAX_DRIVE_KM
@@ -60,14 +60,9 @@ export function useTransportRoutes(reservations: Reservation[]): Map<number, [nu
if (!profile) continue
const wps = orderedWaypoints(r)
if (wps.length < 2) continue
// Per leg, not over the whole booking: a road trip with stops is long by design
// and its total would trip a limit meant to catch a single impossible hop. It is
// one leg spanning an ocean that the road router cannot resolve, not the sum (#1797).
let tooFar = false
for (let i = 0; i < wps.length - 1; i++) {
if (haversineKm(wps[i], wps[i + 1]) > MAX_ROUTE_KM) { tooFar = true; break }
}
if (tooFar) continue
let dist = 0
for (let i = 0; i < wps.length - 1; i++) dist += haversineKm(wps[i], wps[i + 1])
if (dist > MAX_ROUTE_KM) continue
const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}`
if (attempted.get(r.id) === key) continue
attempted.set(r.id, key)
-7
View File
@@ -42,13 +42,6 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
.journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; }
.journey-feed-scroll::-webkit-scrollbar { display: none; }
/* Road trip rail: hide the scrollbar of the day list. The totals card is pinned
above the list and therefore outside its scroll box, so a visible scrollbar
would take width from the day cards alone and leave the two stacks a few
pixels out of line — the one thing a column of nested cards cannot hide. */
.roadtrip-rail-scroll { scrollbar-width: none; -ms-overflow-style: none; }
.roadtrip-rail-scroll::-webkit-scrollbar { display: none; }
/* Journey video: let the player fill the box the lightbox gives it.
*
* Plyr sizes its wrapper from the video's intrinsic aspect ratio via a
@@ -25,12 +25,11 @@ interface MAdminSettingsSectionProps {
export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectionProps) {
const {
toast,
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled,
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled,
placesPhotosEnabled, setPlacesPhotosEnabledState,
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState,
placeShadowEnabled, setPlaceShadowEnabledState,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
@@ -345,26 +344,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
}
/>
<MAdminRow
title={t('admin.placeShadow.title')}
hint={t('admin.placeShadow.subtitle')}
trailing={
<MToggle
checked={placeShadowEnabled}
ariaLabel={t('admin.placeShadow.title')}
onChange={async (next) => {
setPlaceShadowEnabledState(next)
setPlaceShadowEnabled(next)
try {
await adminApi.updatePlaceShadow(next)
} catch {
setPlaceShadowEnabledState(!next)
setPlaceShadowEnabled(!next)
}
}}
/>
}
/>
</div>
{/* Open-Meteo weather info */}
@@ -84,7 +84,6 @@ export default function ImpListStep({ planner, onBack, onDone }: ImpListStepProp
<div className="mt-3 font-geist text-[0.71875rem] leading-[1.45] text-m-muted">
{t(provider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
{provider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
</div>
<input
@@ -16,7 +16,6 @@ import PlTimeFields from './PlTimeFields'
import PlFileAttach from './PlFileAttach'
import type { Place } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { useLocationBias } from '../../../../hooks/useLocationBias'
export interface MPlaceEditSheetProps {
planner: TripPlanner
@@ -147,10 +146,25 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPlaceForm, editingPlace, prefillCoords, editingAssignmentId])
// The area being planned, as a hint for search and autocomplete. Same helper
// as the desktop dialog: the day currently open first, the whole trip only
// while it still fits inside one region.
const { box: locationBias } = useLocationBias()
// Trip-centre bias for search/autocomplete, skipped past ~500 km diagonal.
const locationBias = useMemo(() => {
const withCoords = (places || []).filter(p => p.lat != null && p.lng != null)
if (withCoords.length === 0) return undefined
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
for (const p of withCoords) {
const lat = Number(p.lat), lng = Number(p.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
if (lng < minLng) minLng = lng
if (lng > maxLng) maxLng = lng
}
if (!Number.isFinite(minLat)) return undefined
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
const diagKm = Math.sqrt(((maxLat - minLat) * 111) ** 2 + ((maxLng - minLng) * 111 * Math.cos(avgLatRad)) ** 2)
if (diagKm > 500) return undefined
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
}, [places])
const handleChange = (field: keyof PlaceFormData, value: string) => {
// Typed by hand, so the next pick must leave it alone.
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, Search } from 'lucide-react'
import { mapsApi } from '../../../../api/client'
import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession'
import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../../../utils/apiError'
import { pointFromBox } from '../../../../hooks/useLocationBias'
import { FIELD_CLS } from './PlSheetChrome'
import type { TripPlanner } from '../MTripShell'
@@ -72,10 +70,6 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
const abortRef = useRef<AbortController | null>(null)
// One Google billing session per search (see utils/placesSession).
const placesSessionRef = useRef(new PlacesSession())
// What produced the list on screen, for the shadow log. Refs, because a pick
// has to read the values that belonged to that list. Mirrors PlaceFormModal.
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
const setResolving = useCallback(
(v: boolean) => {
@@ -92,7 +86,6 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
abortRef.current = controller
try {
const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query: input, source: result.source || 'unknown' }
setSuggestions(result.suggestions || [])
} catch (err: unknown) {
// Superseded request — axios rejects an aborted call with CanceledError.
@@ -117,32 +110,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
}
}, [query, fetchSuggestions])
/**
* `pick` is present only when the place came out of a ranked list, so a
* coordinate paste or a resolved Google URL contributes no row.
*/
const applyPlace = (place: MapsPlace, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
const applyPlace = (place: MapsPlace) => {
onPick(placeToPick(place))
if (pick) {
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
const lat = Number(place.lat)
const lng = Number(place.lng)
if (meta && Number.isFinite(lat) && Number.isFinite(lng)) {
recordPlacePick({
query: meta.query,
lang: language,
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
source: `${pick.mode}:${meta.source}`,
liveRank: pick.rank,
liveCount: pick.count,
pickedName: String(place.name ?? ''),
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: (place.google_place_id as string) || (place.osm_id as string) || null,
})
}
}
setResults([])
setSuggestions([])
setQuery('')
@@ -178,10 +147,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
return
}
}
// Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche
// braucht ihn genauso, nur als Punkt statt als Kasten.
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias))
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
const result = await mapsApi.search(trimmed, language)
setResults(result.places || [])
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
@@ -192,9 +158,6 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
}
const handleSelectSuggestion = async (suggestion: Suggestion) => {
// Read before the list is cleared: this is the rank the user saw.
const acRank = suggestions.findIndex(s => s.placeId === suggestion.placeId)
const acCount = suggestions.length
setSuggestions([])
const previousQuery = query
setQuery('')
@@ -213,11 +176,11 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
}
if (!place) {
const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(fullQuery, language, pointFromBox(locationBias))
const search = await mapsApi.search(fullQuery, language)
place = (search.places?.[0] as MapsPlace | undefined) ?? null
}
if (place) {
applyPlace(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
applyPlace(place)
} else {
setQuery(previousQuery)
toast.error(t('places.mapsSearchError'))
@@ -284,7 +247,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
<button
key={idx}
type="button"
onClick={() => applyPlace(result, { mode: 'search', rank: idx, count: results.length })}
onClick={() => applyPlace(result)}
className="block w-full border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left first:border-t-0"
>
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{String(result.name ?? '')}</div>
+6 -65
View File
@@ -517,65 +517,6 @@ describe('AdminPage', () => {
});
});
describe('FE-PAGE-ADMIN-023b: Transit provider select in Settings tab (#1699)', () => {
it('choosing Google calls PUT /api/admin/transit-provider', async () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/admin/transit-provider', () =>
HttpResponse.json({ provider: 'transitous', googleKeySource: 'instance' })),
http.put('/api/admin/transit-provider', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ provider: 'google', googleKeySource: 'instance' });
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const select = await screen.findByRole('combobox', { name: /transit provider/i });
await waitFor(() => expect(select).toHaveValue('transitous'));
fireEvent.change(select, { target: { value: 'google' } });
await waitFor(() => expect(capturedBody).toEqual({ provider: 'google' }));
expect(select).toHaveValue('google');
});
});
describe('FE-PAGE-ADMIN-023c: Transit provider key warnings (#1699)', () => {
async function openSettingsWith(googleKeySource: string | null) {
server.use(
http.get('/api/admin/transit-provider', () =>
HttpResponse.json({ provider: 'google', googleKeySource })),
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
await screen.findByRole('combobox', { name: /transit provider/i });
}
it('warns that Google is selected but no key is configured', async () => {
await openSettingsWith(null);
expect(await screen.findByText(/no google api key is configured/i)).toBeInTheDocument();
});
it('warns that only the admin\'s own key is set, so others fall back', async () => {
await openSettingsWith('user-row');
expect(await screen.findByText(/only your own google key is set/i)).toBeInTheDocument();
});
it('stays quiet when an instance-wide key resolves', async () => {
await openSettingsWith('instance');
await waitFor(() =>
expect(screen.getByRole('combobox', { name: /transit provider/i })).toHaveValue('google'));
expect(screen.queryByText(/no google api key is configured/i)).not.toBeInTheDocument();
expect(screen.queryByText(/only your own google key is set/i)).not.toBeInTheDocument();
});
});
describe('FE-PAGE-ADMIN-024: JWT rotation modal opens from Danger Zone', () => {
it('clicking Rotate in Danger Zone opens the JWT rotation confirmation modal', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1091,7 +1032,7 @@ describe('AdminPage', () => {
// Wait for the API Keys section to appear
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
// Type in the maps key field (type="password" by default)
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
@@ -1122,7 +1063,7 @@ describe('AdminPage', () => {
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
// The Unsplash key is the second 'Enter key...' input (after Maps).
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
@@ -1155,15 +1096,15 @@ describe('AdminPage', () => {
// Wait for the API Keys section
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
// Type a key value to enable the Test button
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
fireEvent.change(keyInputs[0], { target: { value: 'test-maps-key' } });
// The maps key is the only one with a Test button now: weather left the card
// when it stopped needing a key at all.
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^test$/i }));
// Click the validate (Test) button for maps key — first "Test" button in the card
const testBtns = within(apiKeysCard!).getAllByRole('button', { name: /^test$/i });
fireEvent.click(testBtns[0]);
await waitFor(() => {
// After validation, valid indicator appears (admin.keyValid = 'Connected')
+7 -136
View File
@@ -8,7 +8,6 @@ import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
import PlaceInspector from '../components/Planner/PlaceInspector'
@@ -67,13 +66,6 @@ const ExpenseModal = lazyWithRetry(() =>
import('../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
)
const CollabPanel = lazyWithRetry(() => import('../components/Collab/CollabPanel'))
const RoadtripSidebar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripSidebar'))
const RoadtripCorridorPanel = lazyWithRetry(() => import('../components/Roadtrip/RoadtripCorridorPanel'))
const RoadtripLimitsCard = lazyWithRetry(() => import('../components/Roadtrip/RoadtripLimitsCard'))
const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStopPopup'))
const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal'))
const RoadtripTrackModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripTrackModal'))
const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar'))
// Already rendered conditionally, so lazy bites immediately. Worth it beyond its
// own 63 kB: it is the only path to TransitSearchPanel, which drags in tz-lookup
// — about 200 kB of packed zone geometry that every trip used to load.
@@ -251,8 +243,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
leftWidth, rightWidth,
@@ -262,15 +252,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
dropPoiOnRoute,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
@@ -287,7 +268,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
@@ -318,9 +299,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<CompassMap | null>(null)
// The corridor search draws into the same map channel and answers the same question for
// a drive, so the explore pill stands down while road trip mode is on.
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
// page level so it has tripMembers / base currency / current user available.
@@ -394,15 +373,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tripId={tripId}
places={mapPlaces}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripRoutes.lines : route}
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
route={route}
routeVias={routeVias}
showTransitRoutes={transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to
// know which automated transports may ride it (#2019).
days={days}
selectedDayId={selectedDayId}
routeSegments={roadtripActive ? roadtripRoutes.segments : routeSegments}
routeSegments={routeSegments}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
@@ -423,47 +401,12 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
pois={roadtripActive
/* `visible`, not `search.results`: the map is the picture of this very
list, and filtering the list while seventy pins stay on the map no
longer answers "which of these". */
? roadtripCorridor.visible
: poi.pois}
onPoiClick={handlePoiClick}
// Only while road trip mode is on: outside it there is no drive to drop onto.
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
// Clicking the drawn route puts a via there; only in road trip mode, where
// the route is the thing being worked on.
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
alternativeRoutes={alternativeOverlays}
focusPoints={alternativeFocusPoints}
clusterLoosely={roadtripActive}
activeAlternative={highlightedAlternative}
onChooseAlternative={chooseRouteAlternative}
onHighlightAlternative={setHighlightedAlternative}
onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
pois={poi.pois}
onPoiClick={openAddPlaceFromPoi}
onViewportChange={poi.onViewportChange}
onMapReady={setGlMap}
/>
{/* Over the map rather than in a dialog: the answer to "which of these" is the
roads drawn behind it, so covering them to ask would hide the point. */}
{routeAlternatives.open && (
<div style={{ position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 26, pointerEvents: 'none', display: 'flex', justifyContent: 'center' }}>
<LazyPanel id="roadtrip-alternatives">
<RoadtripAlternativesBar
open={routeAlternatives.open}
overlays={alternativeOverlays}
onChoose={chooseRouteAlternative}
onClose={routeAlternatives.close}
onHighlight={setHighlightedAlternative}
/>
</LazyPanel>
</div>
)}
{(poiPillEnabled || glMap) && (
<div className="hidden md:flex" style={{
position: 'absolute', top: 14,
@@ -525,27 +468,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
transition: 'width 0.25s ease',
opacity: leftHidden ? 0 : 1,
}}>
{enabledAddons.roadtrip && (
<RoadtripModeSwitch active={roadtripMode} onChange={(v) => { if (v !== roadtripMode) toggleRoadtripMode() }} />
)}
{enabledAddons.roadtrip && roadtripMode ? (
<LazyPanel id="roadtrip-rail">
<RoadtripSidebar
routes={roadtripRoutes}
selectedAssignmentId={selectedAssignmentId}
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
openAlternatives={routeAlternatives.open}
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
viaCounts={roadtripViaCounts}
trackNames={followTrack.namesByDay}
/>
</LazyPanel>
) : (
<DayPlanSidebar
isMobile={isMobile}
tripId={tripId}
@@ -597,7 +519,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }}
onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined}
/>
)}
{!leftHidden && !narrowPanels && (
<div
role="presentation"
@@ -648,23 +569,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
/>
)}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
{roadtripActive ? (
<LazyPanel id="roadtrip-corridor">
{/* No Add button for someone who may not add: openAddPlaceFromPoi
returns silently without the permission, which reads as a broken
button rather than a missing one. */}
<RoadtripCorridorPanel
corridor={roadtripCorridor}
routes={roadtripRoutes}
onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined}
/>
{/* Under the search, because the limits are read while looking at
what the drive is doing rather than set up front. */}
<div className="px-3.5 pb-3.5">
<RoadtripLimitsCard onSave={saveRoadtripLimit} />
</div>
</LazyPanel>
) : (
<PlacesSidebar
tripId={tripId}
places={places}
@@ -684,7 +588,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
days={days}
isMobile={false}
/>
)}
</div>
</div>
</div>
@@ -936,7 +839,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
)}
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill surface="trip-tab" className="w-full h-full" />
@@ -944,37 +846,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
)}
</div>
{/* The small way in for something found along the drive. Mounted only while a
draft exists, so the chunk stays unloaded for anyone not using road trip mode. */}
{/* How long a stop takes. Its own gate, not the corridor draft's: a stay is set on
any stop of the trip, not only on something just found along the route. */}
{/* Which track a day drives along. Mounted only while it is open, so the chunk and
the parsing of every imported line stay out of an ordinary planner session. */}
{followTrack.dayId !== null && (
<LazyPanel id="roadtrip-track">
<RoadtripTrackModal
follow={followTrack}
dayNumber={roadtripRoutes.days.find(d => d.dayId === followTrack.dayId)?.dayNumber ?? 0}
/>
</LazyPanel>
)}
{stayDraft && (
<LazyPanel id="roadtrip-stay">
<RoadtripStayModal stop={stayDraft} onClose={() => setStayDraft(null)} onSave={setRoadtripStay} />
</LazyPanel>
)}
{stopDraft && (
<LazyPanel id="roadtrip-stop">
<RoadtripStopPopup
draft={stopDraft}
duplicateName={stopDraftDuplicate}
onClose={() => setStopDraft(null)}
onSave={saveStopDraft}
onSaveNight={saveStopDraftAsNight}
onMoreDetails={stopDraftToForm}
/>
</LazyPanel>
)}
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} />
<TripFormModal
isOpen={showTripForm}
@@ -141,39 +141,6 @@ function baseState(): HookState {
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
tripAccommodations: [],
setTripAccommodations: vi.fn(),
// Road trip mode off, which is the state every assertion below reads: the page keeps
// its ordinary map, sidebar and route wiring, and the road-trip branches stay unmounted.
// The shapes still have to be here, because the page reaches into routeAlternatives.open,
// roadtripRoutes.lines and roadtripVias.byDay before it ever checks whether the mode is on.
roadtripMode: false,
toggleRoadtripMode: vi.fn(),
roadtripActive: false,
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
roadtripViaCounts: {},
stopDraft: null,
setStopDraft: vi.fn(),
saveStopDraft: vi.fn(async () => undefined),
stopDraftToForm: vi.fn(),
stopDraftDuplicate: null,
reorderRoadtripStop: vi.fn(async () => undefined),
roadtripVias: { byDay: {}, refresh: vi.fn(async () => undefined) },
addRoadtripVia: vi.fn(async () => undefined),
moveRoadtripVia: vi.fn(async () => undefined),
removeRoadtripVia: vi.fn(async () => undefined),
routeAlternatives: { open: null, close: vi.fn(), loading: false, ask: vi.fn(async () => undefined) },
askRouteAlternatives: vi.fn(async () => undefined),
chooseRouteAlternative: vi.fn(async () => undefined),
alternativeOverlays: [],
alternativeFocusPoints: [],
stayDraft: null,
setStayDraft: vi.fn(),
setRoadtripStay: vi.fn(async () => undefined),
highlightedAlternative: null,
setHighlightedAlternative: vi.fn(),
moveRoadtripStopToDay: vi.fn(async () => undefined),
dropPoiOnRoute: vi.fn(async () => undefined),
allowedFileTypes: 'pdf',
tripMembers: [],
setTripMembers: vi.fn(),
@@ -374,15 +374,11 @@ describe('AdminSettingsTab', () => {
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment');
});
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
it('FE-ADMSET-031: shows the Open-Meteo info block', () => {
renderTab();
const keys = card('API Keys');
expect(within(keys).getByRole('img', { name: 'TREK Places API' })).toBeInTheDocument();
expect(within(keys).getByText('Recommended default')).toBeInTheDocument();
// Weather needs no key, so it left a card about keys. The panel itself lives
// on in the mobile section, where FE-MOB-ASET-019 asserts it.
expect(screen.queryByText('Weather Data')).not.toBeInTheDocument();
expect(screen.getByText('Weather Data')).toBeInTheDocument();
expect(screen.getByText('16-day forecast')).toBeInTheDocument();
});
it('FE-ADMSET-032: the API keys save button calls handleSaveApiKeys', () => {
@@ -493,51 +489,4 @@ describe('AdminSettingsTab', () => {
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(true);
});
describe('FE-ADMSET-041: the index switch', () => {
// The one control on this card about egress rather than about spending:
// with the index on, every search and every keystroke in the place box
// leaves the instance. The flag was read by six call paths and written by
// nothing, so before this there was no way to turn it off short of an
// INSERT against app_settings by hand.
const switchFor = () =>
screen.getByRole('button', { name: /trek place index/i });
it('turns the index off and tells the server', async () => {
let sent: unknown = null;
server.use(
http.put('/api/admin/trek-places', async ({ request }) => {
sent = await request.json();
return HttpResponse.json({ enabled: false });
}),
);
const admin = renderTab({ trekPlacesEnabled: true });
expect(switchFor()).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(switchFor());
expect(admin.setTrekPlacesEnabledState).toHaveBeenCalledWith(false);
await waitFor(() => expect(sent).toEqual({ enabled: false }));
});
it('rolls back when the save fails, instead of showing a state the server never took', async () => {
server.use(http.put('/api/admin/trek-places', () => HttpResponse.error()));
const admin = renderTab({ trekPlacesEnabled: true });
fireEvent.click(switchFor());
await waitFor(() => expect(admin.setTrekPlacesEnabledState).toHaveBeenLastCalledWith(true));
});
it('says what answers instead, but only while it is off', () => {
renderTab({ trekPlacesEnabled: false });
expect(screen.getByText(/place search uses openstreetmap/i)).toBeInTheDocument();
expect(switchFor()).toHaveAttribute('aria-pressed', 'false');
});
it('stays quiet about the fallback while the index is on', () => {
renderTab({ trekPlacesEnabled: true });
expect(screen.queryByText(/place search uses openstreetmap/i)).not.toBeInTheDocument();
});
});
});
File diff suppressed because it is too large Load Diff

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