Compare commits

..

51 Commits

Author SHA1 Message Date
github-actions[bot] ed8acf4c4b chore: bump version to 4.3.1 [skip ci] 2026-09-22 12:17:29 +00:00
Maurice a280517ff3 Merge pull request #2450 from liketrek/audit/431-fixes
fix(release): the audit round over v4.3.1
2026-09-22 13:30:38 +02:00
Maurice 2dd77aaac1 docs(wiki): the pages the 4.3.1 changes left behind
The check-in is an anchor now, not an earliest arrival, and a booked night
leads its check-in day, which the road trip and accommodation pages said
otherwise. Upgrading gains a 4.3.1 section for the reseat migration and for
what better-sqlite3 13 asks of a source install. The collaboration page knows
that links are chips and editable, the audit log knows the new admin action
and the SSO register row, and the MCP pages know the provider parameter and
the Google-only switch.
2026-09-22 13:30:12 +02:00
Maurice 85b5756e89 fix(client): the audit round over the 4.3.1 release
Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
2026-09-22 13:30:12 +02:00
Maurice cb85ba915c fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges
The corridor search in the browser cut its tiles around a ride, the MCP tool
did not: it tiled and measured along the straight line between two terminals,
so an assistant searching a flight day got boxes and hits under the flight
path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use
it. A departure less than the check-in allowance after midnight no longer
pins on the previous evening, and a hire car whose pick-up desk has no
position is not seamed at its return desk.
2026-09-22 13:30:12 +02:00
Maurice 81292d4d2b fix(server): the booked night settles in one pass, and the sibling paths seat it too
The audit over the 4.3.1 release found the seating rule reading a night by one
value and its neighbours by another, so two nights on one day never agreed:
the migration reported moves that changed nothing, flipped the pair on the
next run and dropped the road drawn between them, and in the service every
notes edit swapped them. One rule now lives in accommodations/night-seat.ts,
measures every night by its own check-in, breaks a tie by the booking made
first, and carries the vias; the service, the reseat migration and a trip
date change all seat through it, the last of which used to append the night
at the end of its day again. An account created by a first SSO login writes
the user.register row the password path writes. Tests cover the equal and
absent check-ins, the date change, the booking form path and a day that is
nothing but its night.
2026-09-22 13:30:12 +02:00
Maurice 757967bf18 fix(map): the location button follows the viewport instead of the mount
Both renderers read window.innerWidth once while rendering, so the button
that turns GPS on was decided by the width the map happened to see when it
mounted: a phone turned sideways crosses the 768px cut and kept whatever it
had, and turning back did not bring it home either. Both now use the same
useIsPhone the shell switches on. The test stub for matchMedia answers width
queries from window.innerWidth and re-evaluates on a resize, so a breakpoint
can be driven from a test the way the browser drives it.
2026-09-22 12:57:04 +02:00
Maurice b0c19bfac1 fix(settings): the client registration form says what the server refused
Registering an OAuth client by hand and getting only "Failed to register
OAuth client" left nothing to act on: the server names the rule it broke (a
scope, the ten-client cap, a redirect URI it will not take) and the toast
swallowed it in a bare catch. Both shells show the server's message now and
keep the generic text for a failure that carries none.
2026-09-22 12:12:41 +02:00
Maurice da32eb8a3c fix(mcp): answer the discovery documents under the server address too
A client handed https://host/mcp as the server address looks for the
well-known documents below that path instead of asking the origin, and every
one of those probes fell through to the web app: 200 with an HTML body, which
reads to the client as "this server has no OAuth" (Open WebUI reports it as an
unexpected mimetype). The three documents are served under /mcp/.well-known
now, anything else there answers a JSON 404, and the RFC 8414 path-inserted
form /.well-known/oauth-authorization-server/mcp serves the metadata instead
of 404. The wiki says which redirect URI belongs to the client and that a
plain-HTTP APP_URL leaves the discovery document pointing at localhost.
2026-09-22 12:12:41 +02:00
Maurice a44df6cf11 fix(planner): place edit and delete controls follow place_edit, not day_edit
With places set to "Trip owner" a member who may edit days still saw Edit and
Delete on a place in the day plan's context menu, the inspector's action row,
its inline rename and the phone timeline's edit circle, and every one of them
ended in the server's 403. The day plan gates the two entries on place_edit
(taking a place off the day stays a day right), the inspector drops its edit,
delete and update handlers without the right the way the places pool already
does, the phone timeline offers the pencil only with it, and openPlaceEditor
and handleDeletePlace guard like their siblings.

Closes #2446
2026-09-22 11:29:43 +02:00
Maurice e510ff25ab fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false. The nested objects a tool takes, authored inline or spread from a
shared schema (legs, stops, links, transit places), are strict at this
boundary for the same reason; the shared schemas themselves stay open for
REST. A whole Zod schema, the way a plugin's manifest-built tool arrives,
passes through as its author wrote it.
2026-09-22 11:06:13 +02:00
Maurice 34f3aeee5f fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 11:06:13 +02:00
Maurice bdd326163f fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 10:32:08 +02:00
Maurice b1baf3baa9 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 10:32:08 +02:00
Maurice fdab3bb347 Merge pull request #2437 from liketrek/fix/mcp-search-provider-test
test(mcp): search_place hands the provider option on
2026-09-21 23:05:25 +02:00
Maurice d288459449 test(mcp): search_place hands the provider option on, and refuses any but Google
The tool passes `{ googleOnly }` to the search since #2436; four assertions
still expected the four-argument call. Pinned the new argument, and the one
case the contract added: `provider: 'google'` reaches the search as
`googleOnly: true`, any other value is refused by the schema before the
search is asked.
2026-09-21 23:04:26 +02:00
Maurice e35d95da71 Merge pull request #2436 from liketrek/feat/places-google-only
feat(places): send a search to Google, once or always
2026-09-21 22:23:59 +02:00
Maurice 6b1d65b06c fix(i18n): the Google-only strings carried a doubled escape in English 2026-09-21 22:21:02 +02:00
Maurice b6fcaced2e feat(planner): the places filter is one select, not four pills
With the tracks tab the pill row had grown past what the rail can hold: four
pills sharing one line clipped a word as soon as the panel narrowed. The
filter is the app's own select now, the count riding on the trigger the way
it rode on the active pill, and every option carrying its own in the menu.
2026-09-21 22:18:51 +02:00
Maurice e12ae950df feat(places): send a search to Google, once or always
Since 4.3.0 the TREK index and OpenStreetMap answer every search first and
Google is only asked when they find nothing, so a list with the wrong place
on it never reached Google, whatever key the instance had, and the provider
choice only decided who fills the empty case.

Two ways to change that, both only where Google holds the key slot. Under a
result list that did not come from Google, one quiet line sends the same
query to Google Places alone (`provider: 'google'` on the search contract,
the MCP search tool takes it too). And beside the key, an admin switch
"Search with Google only" hands every search and every suggestion to Google
and asks nothing of the index or OpenStreetMap; off by default, read by the
search after the keyed provider so an install without a key, or one on Amap
or OpenStreetMap, is untouched. The panel says so beside the switch.

Both shells share the one rule for when the line is offered (placeSource),
and the wiki names both switches.
2026-09-21 22:14:44 +02:00
Maurice 11b1399b5c fix(pdf): list a day the way the plan lists it, without the booked night's stop
Booking a night writes the hotel onto its check-in day as a stop, and since
4.3.1 that stop heads its day. The day plan hides it and shows the booking as
the day's accommodation block, but the desktop toolbar hands the export the
store's own assignments, filter and all, so the PDF printed the hotel place
above a flight that left in the morning.

The export now drops that stop before it merges the day, as the list does. The
mobile export already received the filtered set and was right all along.

Closes #2434
2026-09-21 20:54:23 +02:00
Maurice 0644106f7a fix(collections): a marker click shows the place, every time
A marker click toggled the selection, so the second click on the marker of the
place that was open closed it again, which read as the details refusing to
open: every other click on the same marker showed nothing. A marker click now
selects and never clears; the map background and the close control do that,
and a list row still toggles as before.

Closes #2431
2026-09-21 19:35:41 +02:00
Maurice 903378f8ad fix(maps): build the day's road from the stops the day plan shows
Booking a night writes the hotel onto its check-in day as a stop, for the road
trip's driving chain. The day plan hides that stop and draws the hotel as the
day's bookends instead, but the route builder behind the map line and the
phone's travel connectors still read it from the store. It carries no time of
its own, and a timed booking is seated behind the last timed stop, so on a day
that opened with a flight the hidden stop led the day: the map drove from the
hotel to the departure airport and back to the hotel after landing.

The builder now leaves the booked night's stop out, as the list does, and the
bookend rules put the hotel where it belongs: behind the flight, on the road
from the arrival airport. A hotel the traveller placed as a stop of their own
is untouched.

Closes #2430
2026-09-21 18:09:44 +02:00
Maurice 84d3dd0c4a fix(roadtrip): a hop between two desks keeps its line and drops the pill
The hire desk beside the terminal is a 200 m leg, and the pill under it read
"0 km in 0 min" where the eye wants the next stop. Under 150 m and two
minutes a leg is a hop: the rail keeps the dashed line, the phone chain skips
the row, and neither offers other ways of driving it. A note a plugin
attached keeps the row.

Refs #2428
2026-09-21 17:16:19 +02:00
Maurice bdb031a1e9 feat(roadtrip): hire car desks, bookings under stops, the ride as one block
A hire car is no seam, but its desks are where the drive starts and ends: the
pick-up opens its day when nothing timed comes first, the return closes the
day it is handed back on, the road runs through both and the range budget
starts from a full tank at the pick-up. The return is only placed when the
booking names a return day and a located return desk.

A table, tickets or a tour on the day hang under the stop they are pinned to
or booked at as chips that open the booking; one for no stop is listed under
the day. A ride that leaves and lands on the same day is one block on the
rail and in the phone chain, not two terminal rows and a band. Both shells
read the same helpers (carrierRide, stopBookings, roadtripRowModel).

Server reads car bookings into the plan context; the MCP descriptions say so.
The pt-BR locale had the three ride strings in Breton, fixed alongside.

Refs #2428
2026-09-21 17:05:42 +02:00
Maurice 0b4230ff4c fix(roadtrip): the booking a terminal opens has somewhere to open
The day panel owns the transport detail dialog, and in road trip mode the
day panel is not mounted: a terminal row, a ride pill and a map endpoint all
set the booking to show and nothing showed it. The rail brings the dialog
along now. Also drops a translation key nothing read.
2026-09-21 16:17:49 +02:00
Maurice fa35966ab8 feat(roadtrip): a flight, train or ferry booking seams the drive at its terminals
The road trip routed straight from one stop to the next, whatever stood
between them: a flight booked for the afternoon became an eight-hour drive
on the map, with the hotel reached by road from a town the traveller had
flown out of (issue #2428). The day plan has treated such bookings as
carriers for a while, and the drive now reads them the same way.

A flight, train, ferry, cruise or bus with located terminals is a seam. Its
two terminals are seated among the day's stops where the day plan lists the
booking, as stops of their own: no number, no stay, no kind, not draggable,
and an id below every assignment and every automatic night, because they
stand for none. The road ends at the departure terminal and starts again at
the arrival one; the leg between them is the ride, in the booking's own
mode, with the timetable's minutes and no distance, and no router is ever
asked for it. The departure terminal is pinned a check-in ahead of the
timetable and left on the timetable's minute, so a drive that cannot make
the airport in time is late the way a missed pin is. A ride that lands on a
later day seats its arrival at the start of that day; with connected days
the join between the two is the ride, and with daily travel times set the
ride runs on the booking's clock through the night, with no day end placed
on a flight.

The map draws the ride as the booking's own arc, the one the day plan
already draws, beside the roads. The day's distance and driving time count
the roads only, the range budget spends nothing on a ride, and after one the
car cannot take it starts afresh. The corridor search leaves the ride out
and drops a hit under the flight path; a via cannot be filed against a
terminal, and other ways of driving are offered for the road into a
departure terminal only. The server plan reads the same three tables and
seats the same stops, so calculate_roadtrip answers the same as the rail.

Closes #2428
2026-09-21 16:03:21 +02:00
Maurice 4be2314856 docs(wiki): name the toolchain npm asks for on install since better-sqlite3 13 2026-09-21 11:09:38 +02:00
Maurice f43b89be13 test(journey): the editor tile for a clip with and without its poster 2026-09-21 11:08:38 +02:00
Maurice ac23773352 fix(journey): the map waits for its first view before it pans to the active entry
The journey map sets its first view on a rAF after the build, and the active
entry asks for its pan on a 50 ms timer. In a background tab, or on a slow
frame, the timer wins. Leaflet does not refuse a pan on a viewless map, it
takes it as the first view with the zoom still undefined: the tile layer aborts
its own add on that, the load event stops halfway, and every entry marker
queued behind it never reaches the map. The catch that was meant for this
never fired, because nothing threw. The next rebuild, after any save on the
page, then tore down markers that were never added and died in Leaflet's icon
removal, and the error boundary took the whole journey page with it.

Both pans go through whenReady now: at once on a map with a view, otherwise
right after the fit lands.
2026-09-21 11:02:01 +02:00
Maurice 8138c83558 fix(journey): the video player brings its own icons instead of asking cdn.plyr.io
Plyr's defaults point at its CDN twice: the icon sprite for the controls is
fetched over XHR, and on teardown the element is pointed at a blank clip there
to abort the stream. connect-src and media-src both refuse that host, so the
clip played but every button in the player was blank, and each close of the
lightbox logged a blocked request and an uncaught XHR error.

The sprite ships with the bundle now, served from this origin, which Plyr then
references directly instead of loading it. plyr's exports field hides the file,
so it goes through the same resolver alias the rtl-text plugin already needed.
The teardown source is empty: that aborts the stream just the same, and the
element is unmounted in the same tick.

Refs #2341
2026-09-21 11:02:01 +02:00
Maurice 141afe708d fix(journey): let the browser read a picked clip, so it previews and gets a poster
A clip picked in the entry editor is previewed through a <video> on an object
URL, and its poster frame is grabbed the same way before anything is uploaded.
The CSP never named media-src, so the browser fell back to default-src, which
does not admit blob: at all. The preview stayed empty and the poster capture
failed silently, so every clip landed without a poster and its thumbnail route
answered 404 (issue #2341, the part that survived 4.3.0). None of it showed in
development, where Vite serves the document and helmet never sees it.

media-src now carries 'self' and blob:. For the clips that still come up
without a poster, because the browser cannot decode them, the entry card and
the editor tile draw the play badge on a black tile instead of asking for a
thumbnail that is 404 by design, the way the gallery already did.

Closes #2341
2026-09-21 10:35:37 +02:00
Maurice 43a3a5217e fix(server): better-sqlite3 13, so the image never compiles it against Node headers
The runtime stage installed python3 and build-essential for one reason: when
better-sqlite3 12.x could not download its prebuilt binary, node-gyp built the
addon against the headers of whatever Node the base image carried that day.
Since Node 24.19 those headers give node::ObjectWrap a cleanup hook that the
runtime cannot unregister, and the first garbage-collected statement takes the
process down with "Assertion failed: (env) != nullptr". The published image
was spared only because its prebuild download had succeeded (issue #2421).

13.x is built on N-API, so it never touches that path, and it ships the binaries
for amd64 and arm64 inside the npm tarball. Nothing is compiled any more, and
the toolchain leaves the Dockerfile. The install runs with --ignore-scripts:
a lockfile-driven npm ci still synthesises `node-gyp rebuild` for the
binding.gyp it finds and ignores the package's gypfile:false (npm/cli#9837),
which without the toolchain would fail the build.

Closes #2421
2026-09-21 10:35:27 +02:00
Maurice d14d83ebaa fix(dashboard): a long trip title cannot widen the hero card
The hero's content grid had an auto column, which takes the title's
min-content width. A title that is one long word made the column, the
tools row and the boarding pass as wide as that word, with edit, clone,
archive and delete pushed off the card. The column is capped at the card
now, and the title breaks inside it and stops at two lines.
2026-09-20 23:08:41 +02:00
Maurice cce64c4030 fix(oidc): a successful SSO login writes the user.login audit row
Every other login method writes one; the SSO callback touched last_login and
login_count and wrote nothing, so an instance moving its users to SSO watched
its audit log go quiet while looking complete. The row is written in the
callback, where the provider has vouched for the user and the client IP is,
next to the role-change row, with method: oidc in the details the way the
passkey login names its method.

Closes #2417
2026-09-20 22:28:35 +02:00
Maurice bf0507ddb5 fix(planner): Google hours that share a half of the day convert whole to 24h
Google writes a range that stays in one half of the day with the meridiem on
the closing time only, "1:00 – 3:00 PM" for one to three in the afternoon.
The 24-hour conversion only saw times carrying their own AM/PM, so the
opener stayed "1:00" beside a converted "15:00". The closing meridiem is
handed back to an opener that has none before converting; a range spelled
on both sides, the 12h display and lines without a meridiem are untouched.

Closes #2412
2026-09-20 20:28:23 +02:00
Maurice 9bd9ff4a80 style(collab): a link chip's title takes its width first, the host gets the rest 2026-09-20 20:11:10 +02:00
Maurice 8845f73eb9 style(collab): the host gives way before the title does, all of it 2026-09-20 20:09:22 +02:00
Maurice 6b09a8bc11 style(collab): the title of a link chip gives way last
Two to a row, the title and the host were shrinking together into two
ellipses. The host shrinks first now, the open glyph takes the favicon's
place on hover instead of room of its own, and the actions are a touch
smaller.
2026-09-20 20:07:39 +02:00
Maurice 67dff455da style(collab): link chips two to a row across the panel
A chip takes half the panel now, one to a row where the panel is narrower
than a container query's 420px; the title and the host give way inside it
instead of capping their width.
2026-09-20 20:04:39 +02:00
Maurice 97a2ea1748 style(collab): the chip actions keep their width
Unfolding them reflowed the row under the pointer, and a chip that grows as
you reach for it is a chip you cannot catch. They sit faint and come forward
on hover instead.
2026-09-20 19:52:40 +02:00
Maurice 8d70d875b3 style(collab): shared links as chips
One chip per link, flowing like tags, instead of a column of rows with four
grey glyphs each. The chip is the link, with the favicon and the host beside
the title; edit, pin and delete are folded into its tail and unfold on hover
or keyboard focus, and stay unfolded where there is no hover. A pinned chip
carries the accent.
2026-09-20 19:50:06 +02:00
Maurice 3dfdc92b2a fix(collab): a shared link can be edited, and its glyph opens it
A link in the Collab tab could only be deleted and added again once its
title or address needed a correction; the update route has taken both all
along. The add dialog edits a link handed to it now, behind a pencil on
each row. The external-link glyph next to a row that was already a link
read as a button doing nothing; it is a link to the address itself now.

Closes #2414
2026-09-20 19:43:52 +02:00
Maurice 84b73d1a83 fix(planner): the last day scrolls into view beside the road trip switch
With the road trip addon on, the desktop panel puts the Days / Road trip
switch above the day list. The list was sized by height alone, so it ran the
switch's height past the panel's clipped edge and the last day could never
be scrolled into view. It is a flex child of the panel now and yields that
height; where nothing sits above it, the height still fills the panel.
2026-09-20 19:34:47 +02:00
Maurice eeda3188c2 Merge pull request #2411 from liketrek/fix/roadtrip-night-reseat-migration
fix(roadtrip): seat the nights booked before this release where their check-in says
2026-09-20 19:28:33 +02:00
Maurice be9a38e3b0 fix(accommodations): a second night on the day counts by its check-in
Two bookings on one day used to settle by whichever was seated last, because
the other night's stop carries no hour of its own. Its check-in counts now,
in the service and in the migration alike.
2026-09-20 18:48:26 +02:00
Maurice 417d0445b9 fix(roadtrip): seat the nights booked before this release where their check-in says
A booked night leads its day now, but the stops the bookings put on their
check-in day before this release sit last, and the backfill for older
bookings appended them too. Every trip planned before the change kept that
order until somebody edited the check-in.

A migration seats every night a booking owns by the same rule the service
applies: first, behind only a stop whose own clock is at or before the
check-in. The drawn roads follow the stops they were drawn after, by the
rules the service uses when it moves a night. Stops the traveller placed
themselves are not touched. Re-runnable.
2026-09-20 18:45:37 +02:00
Maurice a86e4b560a test(accommodations): a quiet edit still corrects a night the clocks contradict
Covers seatedByCheckIn with a check-in set, which the re-seat on a new
check-in no longer passes through, so the accommodations function ratchet
held.
2026-09-20 17:49:54 +02:00
Maurice cd769d9993 fix(roadtrip): a booked night holds its check-in and heads its day
A hotel entered under Days with a check-in at ten was reached at a quarter
past twelve in road trip mode. Two things: the schedule read the check-in
as a floor that held nothing once anything before it set the clock, and
the server seated the night last unless something pinned later pulled it
forward, so two stops without an hour stood ahead of it.

The check-in now pins the night the way its own time would, in the plain
schedule and in the daily window, where a check-in the drive cannot make
is the conflict a missed pin is. The night is seated first on its day;
only a stop with a clock at or before the check-in stays ahead of it, and
the stops without one follow. A new check-in seats the night afresh, a
change of notes leaves a dragged night where it is.

A day that is nothing but its booked night stands among the days now,
with the hotel and its check-in, instead of at the foot of the rail under
"only X so far", which read as the hotel missing from the road trip.

The client puts a stop the server seated at the front where the server
put it, rather than appending it to draw second until the next reload.
2026-09-20 17:26:50 +02:00
Maurice 979a9283cd Merge pull request #2408 from liketrek/fix/431-collection
fix(notices): keep the promise box whole when the release notice has to scroll
2026-09-20 16:56:10 +02:00
Maurice 32f2488774 fix(notices): keep the promise box whole when the note has to scroll
On a 1080p screen at 100% the release notice's right column runs out of
height. The column scrolls, but the promise box is a flex item with
overflow hidden, so it may shrink to nothing and was pressed down to its
label while the paragraphs around it kept their height. Everything inside
the two scrolling columns now keeps its size.

Below 1080px the halves stack and the panel scrolls; the single minmax row
that keeps the two-column layout inside the panel starved the release half
to zero height there. The rows go back to auto, and the ask sticks to the
bottom edge once the note is in view.
2026-09-20 16:48:31 +02:00
304 changed files with 8999 additions and 1370 deletions
+15 -5
View File
@@ -27,7 +27,8 @@ COPY client/ ./client/
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one
# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
@@ -52,13 +53,22 @@ COPY server/package.json ./server/
# changeset, so it costs nothing. Everything copied after this point carries
# --chown=node:node for the same reason — a recursive chown in a later layer
# would copy up every inode it touches and duplicate the whole tree in the image.
#
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
# python3 + build-essential fallback compiled it against whatever Node headers
# the base image had that day, and 24.19+ headers give an addon that aborts with
# "Assertion failed: (env) != nullptr" the first time a statement is collected
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.0
version: 4.3.1
description: Minimal Helm chart for TREK app
appVersion: "4.3.0"
appVersion: "4.3.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.0",
"version": "4.3.1",
"private": true,
"type": "module",
"scripts": {
+14
View File
@@ -23,3 +23,17 @@ export const rtlTextAlias = {
'../dist/mapbox-gl-rtl-text.js',
),
};
/**
* Same shape for Plyr's icon sprite. The package publishes its JS and CSS entries
* and nothing else, so `plyr/dist/plyr.svg` is refused by the resolver although
* the file is right there. The player has to point at a copy on this origin
* (#2341): its default is the sprite on cdn.plyr.io, which the CSP refuses.
*/
export const plyrSpriteAlias = {
find: /^plyr\/dist\/plyr\.svg/,
replacement: path.join(
path.dirname(createRequire(import.meta.url).resolve('plyr')),
'plyr.svg',
),
};
+12 -10
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -301,7 +301,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -320,14 +320,13 @@ export default function App() {
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// A version is a short release tag and nothing else. It arrives over the
// wire and is written to this device's storage, so it is rebuilt from the
// characters a tag may contain and kept only when nothing had to be
// stripped. Anything else is ignored, which also keeps a malformed value
// from being compared against the stored marker and starting an update on
// every launch.
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
if (version && version === reportedVersion) {
// wire and is written to this device's storage, so only a value made of
// the characters a tag may contain is taken, as the match itself. Anything
// else is ignored, which also keeps a malformed value from being compared
// against the stored marker and starting an update on every launch.
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
if (releaseTag) {
const version = releaseTag[0]
const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private
@@ -360,6 +359,9 @@ export default function App() {
return
}
}
// Last, so the version block above keeps its lines: the taint analyser
// re-raises its browser-storage finding for every line that moves under it.
if (config?.places_provider) setPlacesProvider(config.places_provider)
}).catch(() => {})
}, [])
+9 -2
View File
@@ -671,6 +671,8 @@ export const adminApi = {
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),
getPlacesGoogleOnly: () => apiClient.get('/admin/places-google-only').then(r => r.data),
updatePlacesGoogleOnly: (enabled: boolean) => apiClient.put('/admin/places-google-only', { 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),
@@ -1214,14 +1216,19 @@ export const mapsApi = {
* caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order,
* and a plugin's row carries its own `source` for a caller that wants to mark it.
*
* `provider: 'google'` sends this one search to Google alone, the "search Google
* instead" link under a list the index answered with the wrong place. The server
* ignores it unless Google holds the keyed slot: without a Google key, or with
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
// Side by side, so the wait is the slower of the two rather than their sum. Only
// the core call may reject: that rejection is what hands withCachedPlaces the
// offline path, and a plugin failure must never trigger it.
const [core, extra] = await Promise.all([
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias, ...(provider ? { provider } : {}) }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
pluginSearchPlaces(query, lang, locationBias),
])
if (extra.length === 0) return core
@@ -1,4 +1,4 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
// FE-COMP-LINKS-001 to FE-COMP-LINKS-013
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -63,7 +65,8 @@ describe('CollabLinks', () => {
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument();
// The chip shows the host, not the whole address, and links to the address itself.
expect(screen.getByText('ferries.example')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
});
@@ -118,6 +121,106 @@ describe('CollabLinks', () => {
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument();
});
it('FE-COMP-LINKS-009: a link can be edited in place, title and address alike', async () => {
// #2414: a link used to be delete-and-add once it needed a correction.
const user = userEvent.setup();
let put: Record<string, unknown> | null = null;
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
http.put('/api/trips/1/collab/links/1', async ({ request }) => {
put = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ link: buildLink({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }) });
}),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /edit link|collab\.links\.edit/i }));
const title = await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i);
expect(title).toHaveValue('Ferry timetable');
await user.clear(title);
await user.type(title, 'Ferry timetable 2026');
const url = screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i);
expect(url).toHaveValue('https://ferries.example/timetable');
await user.clear(url);
await user.type(url, 'https://ferries.example/2026');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(put).toEqual({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }));
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
expect(await screen.findByText('Ferry timetable 2026')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ferry timetable 2026/i })).toHaveAttribute('href', 'https://ferries.example/2026');
});
it('FE-COMP-LINKS-010: the chip is the link and opens the address in a new tab', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
const open = await screen.findByRole('link', { name: /ferry timetable/i });
expect(open).toHaveAttribute('href', 'https://ferries.example/timetable');
expect(open).toHaveAttribute('target', '_blank');
// The host stands beside the title; the whole address would not fit a chip.
expect(screen.getByText('ferries.example')).toBeInTheDocument();
});
it('FE-COMP-LINKS-011: deleting asks first, and the DELETE goes out only once confirmed', async () => {
// The link goes for every member, and on the phone the button is a thumb's
// width from pin: the notes panel guards the same way.
const user = userEvent.setup();
let deleted = false;
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i }));
expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument();
expect(deleted).toBe(false);
expect(screen.getByText('Ferry timetable')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
await waitFor(() => expect(deleted).toBe(true));
await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument());
});
it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => {
// The icon remembers a favicon that failed, and an edit keeps the chip's id,
// so the corrected address kept the generic glyph until the panel remounted.
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })),
http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })),
);
render(<CollabLinks tripId={1} />);
const chip = await screen.findByRole('link', { name: /ferry timetable/i });
fireEvent.error(chip.querySelector('img') as HTMLImageElement);
await waitFor(() => expect(chip.querySelector('img')).toBeNull());
await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i }));
const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i);
await user.clear(url);
await user.type(url, 'https://new.example/page');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img'))
.toHaveAttribute('src', 'https://new.example/favicon.ico'));
});
it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => {
// jsdom lays nothing out, so the rule is the assertion. The phone Collab tab
// mounts this chip; 22px buttons a pixel apart put delete one slip from pin.
const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions');
expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1);
const touch = css.slice(at, css.indexOf('\n}', at));
expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/);
const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? '';
for (const side of ['width', 'height']) {
expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24);
}
// The height comes back through the margin, so the chip itself stays 36px.
expect(action).toMatch(/margin:\s*-4px 0/);
});
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
// collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
+77 -32
View File
@@ -1,11 +1,12 @@
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
import { ExternalLink, Link2, Pencil, Pin, Plus, Trash2, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast'
@@ -18,23 +19,34 @@ interface CollabLink {
pinned: boolean | number
}
function LinkIcon({ url, title }: { url: string; title: string }) {
function LinkIcon({ url }: { url: string }) {
const [failed, setFailed] = useState(false)
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
return (
<span className="collab-link-chip__icon" aria-hidden="true">
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
</span>
)
}
/** The part of the address worth a chip's width: the host, without a leading www. */
function hostOf(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
/**
* The add form as a modal, like the poll and note panels next to it. Inline it
* pushed the list down and gave the panel a second header, which is what made
* this tab read as a different app from its four siblings.
* The add and edit form as a modal, like the poll and note panels next to it.
* Inline it pushed the list down and gave the panel a second header, which is
* what made this tab read as a different app from its four siblings. With a
* link handed in it edits that link (#2414): a link used to be delete-and-add
* once its title or address needed a correction.
*/
function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise<void> }) {
const { t } = useTranslation()
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [title, setTitle] = useState(link?.title ?? '')
const [url, setUrl] = useState(link?.url ?? '')
const [busy, setBusy] = useState(false)
const valid = title.trim().length > 0 && url.trim().length > 0
@@ -43,10 +55,10 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
if (!valid || busy) return
setBusy(true)
try {
await onCreate({ title: title.trim(), url: url.trim() })
await onSave({ title: title.trim(), url: url.trim() })
onClose()
} catch {
// add() already said what went wrong; the form stays open with the input in it.
// The caller already said what went wrong; the form stays open with the input in it.
} finally {
setBusy(false)
}
@@ -66,7 +78,7 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t(link ? 'collab.links.edit' : 'collab.links.add')}</h3>
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -99,6 +111,8 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
// Both are new objects on every render, so depending on them would reload the
// list after each keystroke and drop a link that was just added back out of it.
@@ -168,6 +182,17 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
}
}
const edit = async (link: CollabLink, data: { title: string; url: string }) => {
try {
const d = await collabApi.updateLink(tripId, link.id, data)
setLinks(v => v.map(x => x.id === link.id ? d.link : x))
} catch (err) {
console.error('Failed to edit collab link:', err)
toast.error(t('common.error'))
throw err
}
}
const remove = async (id: number) => {
try {
await collabApi.deleteLink(tripId, id)
@@ -197,36 +222,56 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{/* Content: one chip per link, two to a row (see .collab-link-chip). The chip is
the link, with the host beside the title; the actions sit in its tail. */}
<div className="chat-scroll collab-link-grid" style={{ flex: 1, overflowY: 'auto', padding: '2px 12px 12px' }}>
{links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div className="collab-link-grid__cells">
{links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
<LinkIcon url={link.url} title={link.title} />
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
{/* Keyed on the address: the icon remembers a favicon that failed, and
an edit keeps the chip's id, so a corrected address got the glyph. */}
<LinkIcon key={link.url} url={link.url} />
<span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
</span>
</a>
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
{canEdit && <>
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
<Trash2 size={15} aria-hidden="true" />
</button>
</>}
{canEdit && (
<span className="collab-link-chip__actions">
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} title={t('collab.links.edit')}>
<Pencil size={13} aria-hidden="true" />
</button>
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
)}
</div>
))}
</div>
)}
</div>
{/* Create Modal */}
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
{/* Create and edit modals */}
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
{/* The delete is shared: the link goes for every member, and on the phone the
button is a thumb's width from pin. Same guard as the notes panel. */}
<ConfirmDialog
isOpen={pendingDeleteId !== null}
onClose={() => setPendingDeleteId(null)}
onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)}
title={t('collab.links.confirmDeleteTitle')}
message={t('collab.links.confirmDeleteBody')}
/>
</div>
)
}
@@ -1,4 +1,4 @@
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -1028,4 +1028,47 @@ describe('EntryEditor', () => {
expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('FE-JRN-EDITOR-056: a clip without a poster is a play badge, not a request for its thumbnail (#2341)', () => {
// The thumbnail route answers 404 for such a clip on purpose, and the old
// fallback to /original would have handed an <img> the video file itself.
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry({ id: 10, photos: [clip] }))
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-057: a clip with its poster shows the poster like any photo', () => {
const clip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [clip] }))
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => {
const user = userEvent.setup()
const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => {
// The /original of a video is the video file, which an <img> cannot draw.
const user = userEvent.setup()
const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' }
const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
for (const id of [100, 200]) {
const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement
fireEvent.error(img)
expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`)
}
})
})
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
@@ -11,7 +11,7 @@ import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import MarkdownToolbar from './MarkdownToolbar'
import { DatePicker } from './JourneyDetailPageDatePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A photo whose thumbnail is missing can still be drawn from its original. A
// clip's original is the video file, which no <img> can show, so a clip gets
// no fallback (#2341).
function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) {
if (p.media_type === 'video') return undefined
return (e: SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
if (!img.src.includes('/original')) img.src = photoUrl(p, 'original')
}
}
// No poster to show and falling back to /original would hand an <img> the clip
// itself, so this tile stays a play badge.
function ClipTile() {
return (
<div className="absolute inset-0 bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
)
}
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
entry: JourneyEntry
journeyId: number
@@ -434,7 +455,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
>
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
)}
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -527,7 +552,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{posterlessVideo(p) ? (
<ClipTile />
) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
)}
{idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
)}
@@ -8,7 +8,7 @@ import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
@@ -151,9 +151,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onClick={() => onPhotoClick(allPhotos, i)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
>
{photo.media_type === 'video' &&
photo.provider === 'local' &&
!photo.thumbnail_path ? (
{posterlessVideo(photo) ? (
// Poster-less local video: show a neutral tile.
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : (
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { PhotoImg } from './JourneyDetailPagePhotoGrid'
import type { JourneyPhoto } from '../../store/journeyStore'
function buildPhoto(overrides: Partial<JourneyPhoto> = {}): JourneyPhoto {
return {
id: 1,
entry_id: 10,
photo_id: 515,
sort_order: 0,
shared: 1,
created_at: 0,
provider: 'local',
...overrides,
}
}
describe('PhotoImg', () => {
it('asks for the thumbnail of a photo', () => {
const { container } = render(<PhotoImg photo={buildPhoto()} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
})
it('shows the poster of a clip behind the play badge', () => {
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: 'journey/poster.jpg' })} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
expect(container.querySelector('svg')).not.toBeNull()
})
it('draws no image for a clip without a poster, whose thumbnail route answers 404 (#2341)', () => {
// The tile stays black with the play badge on it. An <img> here would only
// add the broken-image glyph the reporter saw.
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: null })} />)
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
expect(container.firstElementChild?.className).toContain('bg-black')
})
it('still asks the server for a provider clip, which serves its own poster', () => {
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', provider: 'immich', thumbnail_path: null })} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
})
})
@@ -1,6 +1,6 @@
import { Image, Play } from 'lucide-react'
import type { JourneyPhoto } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
const src = photoUrl(photo, 'thumbnail')
@@ -11,12 +11,16 @@ export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; cla
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
style={style}
>
<img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
{/* A clip without a poster keeps the black tile and the play badge; asking
for its thumbnail would only draw the broken-image glyph over them. */}
{!posterlessVideo(photo) && (
<img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
)}
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-008
// FE-JRN-COVER-001 to FE-JRN-COVER-010
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => {
expect(onClick).toHaveBeenCalledOnce()
})
it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => {
// Its thumbnail route 404s on purpose, so an <img> would have been a broken face
// across the whole card.
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-map-pin')).toBeNull()
})
it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => {
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
})
@@ -1,6 +1,7 @@
import { MapPin, Plus } from 'lucide-react'
import { MapPin, Play, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore'
@@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore'
* which needs no tokens at all.
*/
/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */
export interface CoverPhoto extends CardPhoto {
media_type?: string | null
provider?: string | null
thumbnail_path?: string | null
}
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
export interface CoverEntry {
id: number
@@ -39,7 +47,7 @@ export interface CoverEntry {
entry_time?: string | null
mood?: string | null
weather?: string | null
photos?: CardPhoto[]
photos?: CoverPhoto[]
}
interface Props {
@@ -69,8 +77,12 @@ export default function JourneyEntryCover({
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
const photoId = cardPhotoId(entry.photos?.[0])
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const first = entry.photos?.[0]
const photoId = cardPhotoId(first)
// A clip without a poster has no picture to fill the card with; its thumbnail
// route 404s and an <img> pointed at it would draw the broken glyph (#2341).
const posterless = first != null && posterlessVideo(first)
const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const title = cardTitle(entry, t)
const place = cardPlace(entry)
@@ -112,7 +124,9 @@ export default function JourneyEntryCover({
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
) : (
<span className="absolute inset-0 flex items-center justify-center">
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
{posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
</span>
)}
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
setView: vi.fn(),
flyTo: vi.fn(),
panTo: vi.fn(),
// Runs the callback at once on a map with a view, else on its load event.
// The default is the loaded map; the two "no view yet" tests below hold
// the callback back and release it by hand.
whenReady: vi.fn((cb: () => void) => { cb(); }),
getZoom: vi.fn(() => 10),
// Leaflet throws "Set map center and zoom first." out of these until the map
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
@@ -334,13 +338,21 @@ describe('JourneyMap', () => {
expect(mockedMap().flyTo).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-018: focusMarker swallows leaflet errors when the map has no view yet', () => {
it('FE-COMP-JOURNEYMAP-018: focusMarker waits for the first view when the map has none yet', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
// Leaflet does not refuse a pan on a viewless map, it takes it as the first
// view with the zoom undefined, and the tile layer then aborts the load event
// before the markers are added. So the pan has to wait for the fit.
let onLoad: (() => void) | null = null;
vi.mocked(mockedMap().whenReady).mockImplementationOnce((cb: () => void) => { onLoad = cb; });
expect(() => act(() => { ref.current!.focusMarker('e1'); })).not.toThrow();
expect(mockedMap().flyTo).not.toHaveBeenCalled();
act(() => { ref.current!.focusMarker('e1'); });
expect(mockedMap().panTo).not.toHaveBeenCalled();
act(() => { onLoad!(); });
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
expect(mockedMap().setView).not.toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
});
it('FE-COMP-JOURNEYMAP-019: invalidateSize forwards to the leaflet map', () => {
@@ -482,14 +494,22 @@ describe('JourneyMap', () => {
}
});
it('FE-COMP-JOURNEYMAP-029: activeMarkerId falls back to setView while the map has no view yet', () => {
it('FE-COMP-JOURNEYMAP-029: activeMarkerId waits for the first view while the map has none yet', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />);
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
// The 50 ms timer can beat the rAF that sets the first view, in a
// background tab most of all. A pan at that point used to become the first
// view itself, with no zoom, and left the map without its markers; the next
// rebuild then crashed the page in Leaflet's icon removal.
let onLoad: (() => void) | null = null;
vi.mocked(mockedMap().whenReady).mockImplementationOnce((cb: () => void) => { onLoad = cb; });
act(() => { vi.advanceTimersByTime(60); });
// The catch is where a map with no view at all gets its first one.
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
expect(mockedMap().panTo).not.toHaveBeenCalled();
expect(mockedMap().setView).not.toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
act(() => { onLoad!(); });
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
expect(mockedMap().flyTo).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
+23 -13
View File
@@ -161,6 +161,27 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
</div>`
}
/**
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
* and a focus keeps that (discussion #2299).
*
* Deferred until the map has its first view. That view is set on a rAF after
* the build, and the active entry asks for its pan on a 50 ms timer, so a tab
* in the background or a slow frame lets the pan come first. Leaflet does not
* refuse a pan on a viewless map, it takes it as the first view, with the zoom
* still undefined: the tile layer aborts its own add on that, the load event
* stops halfway, and every marker queued behind it never makes it onto the map.
* The next rebuild then tears down markers that were never added and dies in
* Leaflet's icon removal, which took the whole journey page with it after each
* save. whenReady runs the pan at once on a map with a view and otherwise right
* after the fit lands.
*/
function panToMarker(map: L.Map, marker: L.Marker): void {
map.whenReady(() => {
map.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
})
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */
@@ -254,11 +275,7 @@ function JourneyMap(
const focusMarker = useCallback((id: string) => {
highlightMarker(id)
const marker = markersRef.current.get(id)
if (marker && mapRef.current) {
try {
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
} catch { /* map not yet initialized */ }
}
if (marker && mapRef.current) panToMarker(mapRef.current, marker)
}, [])
const invalidateSize = useCallback(() => {
@@ -511,14 +528,7 @@ function JourneyMap(
highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId)
if (!marker || !mapRef.current) return
// Pan, don't zoom — see focusMarker. fitBounds may still be pending when this
// fires, and panTo on a map with no view throws "Set map center and zoom
// first", so the catch is where the map gets its first one.
try {
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
} catch {
mapRef.current.setView(marker.getLatLng(), 12)
}
panToMarker(mapRef.current, marker)
}, 50)
return () => clearTimeout(timer)
}, [activeMarkerId])
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -234,4 +234,25 @@ describe('MobileEntryView', () => {
expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
expect(screen.queryByText('Sunny')).not.toBeInTheDocument()
})
it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => {
// Its original is the video file, which an <img> cannot show, so the hero used
// to be a broken image for every clip, poster or not.
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => {
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
// The second photo's strip thumbnail is the only image left.
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
})
@@ -7,6 +7,7 @@ import {
import JournalBody from './JournalBody'
import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// Labels are translation keys, like the desktop tables in
@@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, 0)}
>
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{/* A clip is drawn from its poster, its original being the video file.
Without a poster the black ground and the play badge are the hero (#2341). */}
{posterlessVideo(photos[0]) ? (
<span className="block aspect-video w-full max-h-[50vh]" />
) : (
<img
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`}
onClick={() => onPhotoClick(photos, i)}
>
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const plyrCtor = vi.fn()
vi.mock('plyr', () => ({
default: class {
el: HTMLElement
original: Node
constructor(el: HTMLElement, options: Record<string, unknown>) {
plyrCtor(el, options)
this.el = el
this.original = el.cloneNode(true)
}
// What the real destroy() does to the document: the element as it was at
// construction goes back in, whatever React wrote to it since.
destroy() {
this.el.replaceWith(this.original)
}
},
}))
vi.mock('plyr/dist/plyr.css', () => ({}))
import { render } from '../../../tests/helpers/render'
import VideoPlayer from './VideoPlayer'
describe('VideoPlayer', () => {
beforeEach(() => { plyrCtor.mockClear() })
it('hands Plyr the sprite from this origin, not the one on cdn.plyr.io (#2341)', () => {
// connect-src refuses cdn.plyr.io, so with the default the player worked and
// every button in it was blank. The sprite ships with the bundle instead.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
expect(plyrCtor).toHaveBeenCalledTimes(1)
const [, options] = plyrCtor.mock.calls[0]
// A hashed asset URL in the bundle, the raw file (with its query) under vitest.
expect(String(options.iconUrl)).toMatch(/plyr[^/]*\.svg(\?|$)/)
expect(String(options.iconUrl)).not.toContain('cdn.plyr.io')
})
it('resets the element to an empty source on teardown rather than a clip on the CDN', () => {
// media-src refuses cdn.plyr.io too; an empty source aborts the stream just the
// same, and the element is out of the document by then.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
const [, options] = plyrCtor.mock.calls[0]
expect(options.blankVideo).toBe('')
})
it('mounts the player on the video element it renders', () => {
const { container } = render(<VideoPlayer src="/api/photos/7/original" poster="/api/photos/7/thumbnail" />)
const [el] = plyrCtor.mock.calls[0]
expect(el).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail')
})
it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => {
// Rebuilding Plyr on the same <video> ran after React had already pointed it at
// the next clip, and destroy() then swapped the first clip back in. The file
// lightboxes do not key the player themselves, so this has to hold here.
const { container, rerender } = render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
rerender(<VideoPlayer src="/api/photos/8/original" autoPlay={false} />)
expect(plyrCtor).toHaveBeenCalledTimes(2)
const [first] = plyrCtor.mock.calls[0]
const [second] = plyrCtor.mock.calls[1]
expect(second).not.toBe(first)
expect(second).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('src', '/api/photos/8/original')
})
})
+29 -12
View File
@@ -1,6 +1,18 @@
import React, { useEffect, useRef } from 'react'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
// The control icons, served from this origin. Plyr's default points at cdn.plyr.io,
// which connect-src refuses: the player worked but every button in it was blank.
// A file, not a data URL, because Plyr tells same-origin from cross-origin by the
// host and would fetch a data URL over XHR, which connect-src refuses as well.
import plyrSprite from 'plyr/dist/plyr.svg?no-inline'
interface Props {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}
/**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
@@ -8,18 +20,17 @@ import 'plyr/dist/plyr.css'
* the source carries the correct video MIME from the server. The Plyr instance is
* created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback.
*
* A new source is a new element: Plyr's destroy() puts a clone of the element as
* it was at construction back into the document, so rebuilding the player on a
* <video> React had already pointed at the next clip left the first clip on
* screen under the next one's name.
*/
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
export default function VideoPlayer(props: Props): React.ReactElement {
return <PlyrVideo key={props.src} {...props} />
}
function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
@@ -40,6 +51,12 @@ export default function VideoPlayer({
autoplay: autoPlay,
clickToPlay: true,
hideControls: false,
iconUrl: plyrSprite,
// On teardown Plyr points the element at a blank clip to abort the stream,
// by default one on cdn.plyr.io, which media-src refuses. An empty source
// aborts the stream just the same, and the element is out of the document
// by then, so the error it would otherwise report has nowhere to show.
blankVideo: '',
})
return () => {
@@ -49,7 +66,7 @@ export default function VideoPlayer({
/* already torn down */
}
}
}, [src, autoPlay])
}, [autoPlay])
return (
<div
@@ -1030,6 +1030,26 @@ describe('MapView live location', () => {
expect(geoMock.cycleMode).toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
geoMock.mode = 'off'
render(<MapView />)
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
// landscape crosses the 768px cut without remounting the map
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
})
it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
const desktop = render(<MapView />)
// The desktop credit is Leaflet's own, in its own container; this component adds none.
+5 -1
View File
@@ -547,6 +547,7 @@ import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } f
import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
// Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
@@ -1033,7 +1034,10 @@ export const MapView = memo(function MapView({
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// The width is followed rather than read once: a phone turned sideways and
// back crosses the breakpoint twice, and the button used to stay with
// whatever the map saw when it mounted.
const isMobile = useIsPhone()
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
+7 -3
View File
@@ -22,6 +22,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
@@ -1159,6 +1160,12 @@ export function MapViewGL({
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy), so
// the location button would be misleading; the phone, where real GPS lives,
// keeps it. Read here with the other hooks rather than beside the button:
// the tokenless branch below returns early, and a hook after it runs on some
// renders and not others.
const isMobile = useIsPhone()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick
@@ -2382,9 +2389,6 @@ export function MapViewGL({
)
}
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
@@ -102,6 +102,46 @@ describe('buildDayRouteRuns', () => {
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
})
it('FE-MAP-DRP-009: the stop a booked night wrote is no waypoint; the hotel is the bookend, behind the flight (#2430)', () => {
// Check-in at four, a flight in the morning, nothing else: the day plan hides the
// hotel's own stop and shows the flight, then the hotel. The road used to start at
// the hotel and drive to the departure airport, because the untimed stop kept
// its stored place ahead of the timed booking.
const hotel = { id: 30, trip_id: 1, place_lat: 53.5465, place_lng: 9.9727, start_day_id: 1, end_day_id: 3, check_in: '16:00', check_out: '11:00' } as unknown as Accommodation
const flight = {
id: 7, trip_id: 1, type: 'flight', title: 'KL 1783', status: 'confirmed', day_id: 1, end_day_id: 1,
reservation_time: '2026-10-19T10:00', reservation_end_time: '2026-10-19T11:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'AMS', lat: 52.3105, lng: 4.7683 },
{ role: 'to', sequence: 1, name: 'HAM', lat: 53.6304, lng: 9.9882 },
],
} as unknown as Reservation
const runs = buildDayRouteRuns(1, inputs({
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })],
assignments: { '1': [at(53.5465, 9.9727, 0, { accommodation_id: 30 })] },
reservations: [flight],
accommodations: [hotel],
optimizeFromAccommodation: true,
}))
// One road: from the arrival airport to the hotel. None out of the hotel.
expect(runs).toHaveLength(1)
expect(runs[0].map(p => [p.lat, p.lng])).toEqual([[53.6304, 9.9882], [53.5465, 9.9727]])
})
it('FE-MAP-DRP-010: a hotel the traveller placed as a stop of their own still ends the road (#2430)', () => {
// The same day, but the hotel stop is not the booking's: it stays a waypoint, and
// the bookend rule declines the zero-kilometre leg onto itself as before.
const hotel = { id: 30, trip_id: 1, place_lat: 53.5465, place_lng: 9.9727, start_day_id: 1, end_day_id: 3, check_in: '16:00', check_out: '11:00' } as unknown as Accommodation
const runs = buildDayRouteRuns(1, inputs({
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 3, day_number: 3 })],
assignments: { '1': [at(53.5503, 9.9937, 0), at(53.5465, 9.9727, 1)] },
accommodations: [hotel],
optimizeFromAccommodation: true,
}))
expect(runs).toHaveLength(1)
expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465])
})
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
+12 -1
View File
@@ -34,7 +34,18 @@ export interface DayRouteInputs {
*/
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
// Without the stop a booked night wrote. The day plan hides that stop (the list
// and `useTripPlanner` both filter it) and draws the hotel as the day's bookends
// instead, so the road has to be built from the same stops the list shows. Left
// in, the stop sat first on a day that opened with a flight, since it carries no
// time of its own and a timed booking is seated behind the last timed stop: the
// map then drove from the hotel to the airport the traveller had not landed at
// yet, and back after the flight (#2430). Road trip mode keeps the stop; it plans
// its own drive and never comes through here.
const da = (assignments[String(dayId)] || [])
.filter(a => a.accommodation_id == null)
.slice()
.sort((a, b) => a.order_index - b.order_index)
const dayOrder = (id: number | null | undefined): number | null => {
if (id == null) return null
const d = allDays.find(x => x.id === id)
+51
View File
@@ -31,6 +31,12 @@ function getIframe(): HTMLIFrameElement | null {
return document.querySelector('#pdf-preview-overlay iframe')
}
/** The cover's "Planned" figure. The stat is read off its label, since days and places print the same markup. */
function plannedStat(html: string): number | null {
const match = /<div class="cover-stat-num">(\d+)<\/div>\s*<div class="cover-stat-lbl">pdf\.planned<\/div>/.exec(html)
return match ? Number(match[1]) : null
}
// ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
@@ -253,6 +259,51 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
})
it('FE-COMP-TRIPPDF-013f: the stop a booked night wrote is not listed, so a morning flight prints before the hotel (#2434)', async () => {
// The desktop toolbar hands the export the store's assignments, hidden stop and
// all, and that stop heads its check-in day. The day plan hides it and shows the
// booking as the accommodation block, and the print lists what the plan lists.
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null, price: '120' } as any
const hotelStop = { id: 201, day_id: 10, place_id: 101, order_index: 0, accommodation_id: 30, place: hotelPlace }
const flight = { ...transportReservation, title: 'Morning flight', reservation_time: '2025-06-01T08:00:00' }
await downloadTripPDF({
...richArgs,
assignments: { '10': [hotelStop, { ...assignmentForDay, order_index: 1 }] } as any,
reservations: [flight],
})
const html = getIframe()!.srcdoc
expect(html).not.toContain('Seewartenstr. 9')
expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum'))
expect(html.indexOf('Morning flight')).toBeGreaterThan(-1)
// The cover and the day header read the same list: the hidden stop is not a planned
// stop and its hotel's price is not part of the day, whichever shell asked.
expect(plannedStat(html)).toBe(1)
const money = html.replace(/[\u00a0\u202f]/g, ' ')
expect(money).toContain('15,00 €')
expect(money).not.toContain('135,00 €')
})
it('FE-COMP-TRIPPDF-013g: a service stop the plan keeps to the road trip view is not printed either, and comes back with the switch', async () => {
// "Show in Days too" off keeps petrol stations and rest areas out of the day lists.
// The print follows the same switch, so the desktop, which hands over the store's
// assignments, and the phone print the same day.
const pump = { id: 102, name: 'Aral Autohof', address: 'Autobahnkreuz 1', stop_type: 'fuel', place_time: null } as any
const pumpStop = { id: 202, day_id: 10, place_id: 102, order_index: 1, place: pump }
const assignments = { '10': [assignmentForDay, pumpStop] } as any
await downloadTripPDF({ ...richArgs, assignments, showServiceStops: false })
const hidden = getIframe()!.srcdoc
expect(hidden).not.toContain('Aral Autohof')
expect(hidden).toContain('Colosseum')
expect(plannedStat(hidden)).toBe(1)
// On, and by default, the stop prints where the plan lists it.
getOverlay()?.remove()
await downloadTripPDF({ ...richArgs, assignments })
const shown = getIframe()!.srcdoc
expect(shown).toContain('Aral Autohof')
expect(plannedStat(shown)).toBe(2)
})
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
const sameDay = { ...transportReservation, reservation_end_time: '2025-06-01T16:45:00' }
await downloadTripPDF({ ...richArgs, reservations: [sameDay] })
+32 -5
View File
@@ -16,6 +16,7 @@ import { renderTripMapImage } from './tripMapImage'
import { formatDistance } from '../../utils/units'
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
/**
* Every day starts a new page by default. On a trip of short days that prints
@@ -199,11 +200,34 @@ interface downloadTripPDFProps {
timeFormat?: string
/** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */
distanceUnit?: string
/**
* The road trip setting "Show in Days too" (`roadtrip_service_stops_in_days`), read as a
* prop for the same reason. Off, the day plan keeps petrol stations and rest areas to
* the road trip view, and so does the print.
*/
showServiceStops?: boolean
}
// `assignments` is normalised here once — every read below (and fetchPlacePhotos)
// relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit }: downloadTripPDFProps) {
/**
* The stops the day plan lists, out of everything the store holds for the trip.
*
* The stop a booked night wrote onto its check-in day is out whatever the switch says:
* the day already shows that booking as its accommodation block, and the row would be
* the same hotel a second time. The service stops go with the setting. One predicate
* for the whole document, so the day lists, the route map, the cover's planned count
* and every cost total agree with the plan and with each other, whichever shell asked.
*/
function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean): AssignmentsMap {
return Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [
dayId,
(list || []).filter(a => a.accommodation_id == null && (showServiceStops || !isServiceStopType(a.place?.stop_type))),
]))
}
// `assignments` is normalised here once, to the plan's own list; every read below
// (and fetchPlacePhotos) relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments: stored = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true }: downloadTripPDFProps) {
const assignments = planAssignments(stored, showServiceStops)
const breaksPerDay = pageBreakPerDay()
const loc = _locale || undefined
const tr = _t || (k => k)
@@ -335,8 +359,11 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
}
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = (assignments[String(day.id)] || []).slice()
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
// What the plan lists (planAssignments): the stop a booked night wrote sat at the
// head of its day, so the hotel printed above a morning flight (#2434).
const assigned = (assignments[String(day.id)] || [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
@@ -101,14 +101,15 @@ vi.mock('../shared/Toast', () => ({
// ── Permissions mock ────────────────────────────────────────────────────────
// Flipped per test to render the read-only variants of the day rows.
const mockPermissions = vi.hoisted(() => ({ canEdit: true }))
// Flipped per test to render the read-only variants of the day rows; `denied`
// takes single actions away from an otherwise editing member.
const mockPermissions = vi.hoisted(() => ({ canEdit: true, denied: new Set<string>() }))
vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any
return {
...actual,
useCanDo: () => () => mockPermissions.canEdit,
useCanDo: () => (action: string) => mockPermissions.canEdit && !mockPermissions.denied.has(action),
}
})
@@ -182,6 +183,7 @@ beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
mockPermissions.canEdit = true
mockPermissions.denied.clear()
// clearAllMocks keeps implementations, so tests that swap the router out would
// otherwise leak into the ones after them.
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
@@ -223,6 +225,17 @@ describe('DayPlanSidebar', () => {
expect(document.body).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-001b: the panel yields to whatever the desktop shell puts above it', () => {
// With the Days / Road trip switch above it, a panel sized by height alone ran the
// switch's height past the clipped edge and the last day could never be scrolled
// into view. jsdom lays nothing out, so the sizing itself is the assertion.
const { container } = render(<DayPlanSidebar {...makeDefaultProps()} />)
const panel = container.firstElementChild as HTMLElement
expect(panel.style.flex).toBe('1 1 0%')
expect(panel.style.minHeight).toBe('0px')
expect(panel.style.height).toBe('100%')
})
it('FE-PLANNER-DAYPLAN-002: renders day titles', () => {
const day = buildDay({ title: 'Amsterdam Day', date: '2025-06-01' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
@@ -927,6 +940,18 @@ describe('DayPlanSidebar', () => {
expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id)
})
it('FE-PLANNER-DAYPLAN-214: without place_edit the menu still removes from the day but neither edits nor deletes the place (#2446)', () => {
mockPermissions.denied.add('place_edit')
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
fireEvent.contextMenu(screen.getByText('Louvre Museum'))
expect(screen.getByText(/Remove from day/i)).toBeInTheDocument()
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
})
// ── Arrow reorder buttons ────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => {
@@ -188,6 +188,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
// Editing or deleting the place itself is a place right; taking it off the
// day stays a day right (#2446).
const canEditPlaces = can('place_edit', trip)
// The calendar subscription hands out a link that reads the trip without an
// account, so it sits behind the same permission as the public share link.
const canManageShare = can('share_manage', trip)
@@ -1148,6 +1151,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
tripActions,
can,
canEditDays,
canEditPlaces,
canManageShare,
noteUi,
setNoteUi,
@@ -1348,6 +1352,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
tripActions,
can,
canEditDays,
canEditPlaces,
canManageShare,
noteUi,
setNoteUi,
@@ -1525,7 +1530,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
return (
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
// Sized as a flex child as well as by height: the desktop panel puts the Days / Road
// trip switch above this, and at height 100% alone the list ran the switch's height
// past the panel's clipped edge, so the last day could never be scrolled into view.
// Where nothing sits above it (the mobile shell), the height still fills the panel.
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0%', minHeight: 0, height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
<DayPlanSidebarToolbar
tripId={tripId}
@@ -2093,13 +2102,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// does not exist here.
const navTargets = getNavigationTargets(place)
ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditPlaces && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}}
onMouseEnter={e => {
@@ -186,6 +186,128 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-018b: a list the index answered offers Google instead, and the link sends the same query there alone', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
const searchRow = searchInput.closest('.flex') as HTMLElement;
await user.click(within(searchRow).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// The quiet line under the list, only because the instance has a key and
// this list did not come from Google.
const retry = screen.getByRole('button', { name: /Search Google instead/ });
await user.click(retry);
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies[0]).not.toHaveProperty('provider');
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
// A list Google produced has nowhere further to go.
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018c: without a Google key the list offers nothing', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018d: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => {
// The server only honours the request while Google holds the keyed slot;
// under another provider the link would re-run the same search and stay.
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018e: the link sends the query the list came from, not what the field holds by then', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })),
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// The list stays while the field is retyped; the line still means this list.
await user.clear(searchInput);
await user.type(searchInput, 'Kyoto');
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies).toHaveLength(2);
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-018f: the link still works after the field was cleared', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// An empty field used to make the click a silent no-op.
await user.clear(searchInput);
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
const user = userEvent.setup();
server.use(
@@ -13,12 +13,12 @@ import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { sourceLabelFor } from '../../utils/placeSource'
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -196,7 +196,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
@@ -396,13 +397,16 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setForm(prev => ({ ...prev, [field]: value }))
}
const handleMapsSearch = async () => {
if (!mapsSearch.trim()) return
const handleMapsSearch = async (provider?: 'google') => {
// The retry sends the query the list came from, not the field: the list
// stays on screen while the field is edited or cleared, and the line under
// it promises the same query.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
if (!trimmed) return
setIsSearchingMaps(true)
try {
// A pasted Google Maps or Amap link resolves server-side into a place
const trimmed = mapsSearch.trim()
if (isMapUrl(trimmed)) {
if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
setForm(prev => ({
@@ -419,8 +423,8 @@ 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(trimmed, language, locationBiasPoint, provider)
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
@@ -760,7 +764,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language,
locale,
timeFormat,
hasMapsKey,
googleAnswers,
placesEnrichEnabled,
can,
tripObj,
@@ -838,7 +842,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast,
t,
language,
hasMapsKey,
googleAnswers,
placesEnrichEnabled,
can,
tripObj,
@@ -1002,6 +1006,22 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
))}
</div>
)}
{/* The index answers first and Google only when it finds nothing, so a
list with the wrong place on it never reaches Google by itself. One
quiet line under the list sends the same query there, on an instance
where Google holds the key slot and for a list Google did not
already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
<button
type="button"
onClick={() => handleMapsSearch('google')}
disabled={isSearchingMaps}
className="mt-1.5 inline-flex items-center gap-1 text-caption text-content-faint hover:text-content transition-colors disabled:opacity-50"
>
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
{t('places.searchGoogleInstead')}
</button>
)}
</div>
{/* Name */}
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { usePluginStore } from '../../store/pluginStore';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import type { AssignmentsMap } from '../../types';
@@ -1478,6 +1479,25 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
});
it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', () => {
// buildUser() is a plain member and buildTrip({ id: 1 }) belongs to somebody else.
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
const onEdit = vi.fn();
const onDelete = vi.fn();
const onUpdatePlace = vi.fn();
const member = render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
fireEvent.doubleClick(screen.getByText(place.name));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
member.unmount();
// the same trip owned by this member shows them again
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) });
render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => {
// Taking it off the day would leave the booking behind with nothing on the drive and
@@ -32,6 +32,7 @@ import type { Place, Category, Day, Assignment, Reservation, TripFile, Assignmen
import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
@@ -184,11 +185,21 @@ interface PlaceInspectorProps {
export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onRate,
leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
// Editing the place is a place right. The planner hands the handlers over
// regardless, and a member without the right saw Edit, Delete and the inline
// rename and got the server's 403 for each (#2446). Collection mode gates
// its own actions.
const can = useCanDo()
const trip = useTripStore(s => s.trip)
const mayEditPlace = mode !== 'trip' || can('place_edit', trip)
const onEdit = mayEditPlace ? editPlace : undefined
const onDelete = mayEditPlace ? deletePlace : undefined
const onUpdatePlace = mayEditPlace ? updatePlace : undefined
// Plugins that declared a place-detail slot mount at the bottom of this panel,
// scoped to the open place (trip mode only). Inline-filter like the other sites.
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
@@ -1,5 +1,5 @@
// FE-COMP-PLACES-001 to FE-COMP-PLACES-015 + FE-PLANNER-SIDEBAR-016 to 043
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
import { render, screen, fireEvent, waitFor, act, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore';
@@ -50,6 +50,12 @@ beforeEach(() => {
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
/** The filter is one select now: open it, then pick the option by its label. */
async function pickFilter(user: ReturnType<typeof userEvent.setup>, label: string) {
await user.click(within(screen.getByTestId('places-filter')).getByRole('button'));
await user.click(await screen.findByRole('button', { name: new RegExp(`^${label}`) }));
}
describe('PlacesSidebar', () => {
it('FE-COMP-PLACES-001: renders without crashing', () => {
render(<PlacesSidebar {...defaultProps} />);
@@ -251,7 +257,7 @@ describe('Filter tabs', () => {
const unplanned = buildPlace({ name: 'Unplanned Place' });
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
await pickFilter(user, 'Unplanned');
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
});
@@ -262,8 +268,8 @@ describe('Filter tabs', () => {
const unplanned = buildPlace({ name: 'Unplanned Place' });
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
await user.click(screen.getByRole('button', { name: /^All/i }));
await pickFilter(user, 'Unplanned');
await pickFilter(user, 'All');
expect(screen.getByText('Planned Place')).toBeInTheDocument();
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
});
@@ -273,7 +279,7 @@ describe('Filter tabs', () => {
const place = buildPlace({ name: 'Assigned Place' });
const assignments = { '1': [buildAssignment({ place, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[place]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
await pickFilter(user, 'Unplanned');
expect(screen.getByText(/All places are planned/i)).toBeInTheDocument();
});
@@ -515,7 +521,7 @@ describe('Category filter dropdown', () => {
const unplanned = buildPlace({ name: 'Unplanned Place' });
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
const { unmount } = render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
await pickFilter(user, 'Unplanned');
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
unmount();
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
@@ -1,6 +1,7 @@
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
import { getCategoryIcon } from '../shared/categoryIcons'
import Tooltip from '../shared/Tooltip'
import CustomSelect from '../shared/CustomSelect'
import { useElementSize } from '../../hooks/useElementSize'
import type { SidebarState } from './usePlacesSidebar'
@@ -177,40 +178,17 @@ export function PlacesHeader(S: SidebarState) {
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }>
return (
// The group always spans the rail and never wraps onto a second line.
// Each tab keeps its own text width and only the leftover space is
// shared out, so "Unplanned" stays wider than "All" instead of the four
// being forced to one size.
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'nowrap' }}>
{tabs.map(f => {
const active = filter === f.id
return (
<button type="button"
key={f.id}
onClick={() => { setFilter(f.id); setSelectedIds(new Set()) }}
className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap',
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.label}</span>
<span className={active ? 'text-accent-text' : 'text-content-faint'} style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1,
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
flexShrink: 0,
}}>
{counts[f.id]}
</span>
</button>
)
})}
// One select where four pills used to fight for the rail's width: with the
// tracks tab the row had grown past what a narrow panel can hold without
// clipping a word. The count rides on the trigger the way it rode on the
// active pill, and every option carries its own in the menu.
<div data-testid="places-filter" style={{ marginBottom: 8 }}>
<CustomSelect
size="sm"
value={filter}
onChange={v => { setFilter(v as 'all' | 'unplanned' | 'planned' | 'tracks'); setSelectedIds(new Set()) }}
options={tabs.map(f => ({ value: f.id, label: f.label, badge: String(counts[f.id]) }))}
/>
</div>
)
})()}
@@ -218,9 +196,9 @@ export function PlacesHeader(S: SidebarState) {
{/* Says out loud what the count above already narrowed to.
The map has followed the open day on this filter since #2024, and until now
nothing anywhere said so: the pool read 55, the map drew five, and the honest
conclusion was that the map was broken. It sits under the tabs rather than on
the map because a chip over the canvas is unreachable on a phone, which is
where this was reported from. */}
conclusion was that the map was broken. It sits under the filter select
rather than on the map because a chip over the canvas is unreachable on a
phone, which is where this was reported from. */}
{dayScoped && (
<div
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
@@ -96,7 +96,8 @@ describe('TripExportModal', () => {
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
trip, days, showServiceStops: true,
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
})
@@ -6,6 +6,7 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import { useSettingsStore } from '../../store/settingsStore'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
/**
* What a GPX download can carry. Worded by what someone wants on their device
@@ -61,6 +62,9 @@ export function TripExportModal({
// The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
// The export gets the store's assignments and applies the day plan's own filter to
// them, so it needs the same switch the plan reads.
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId)
const fileBase = trip?.title || 'trip'
// Shared tail of every download: Firefox and Safari cancel the download when
@@ -87,7 +91,7 @@ export function TripExportModal({
// exported. A missing chunk lands in the catch and shows the same error
// the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit })
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops })
onClose()
} catch (e) {
console.error('PDF error:', e)
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
describe('convertHoursLine', () => {
it('FE-PLANNER-HOURS-001: a range that stays in one half of the day carries the meridiem back to its opener', () => {
// Google writes "1:00 – 3:00 PM" for one to three in the afternoon (#2412). The
// opener used to stay "1:00" next to a converted "15:00".
expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM, 5:00 – 8:00 PM', '24h'))
.toBe('Wednesday: 13:00 – 15:00, 17:00 – 20:00')
})
it('FE-PLANNER-HOURS-002: a range spelled on both sides is converted as written', () => {
expect(convertHoursLine('Monday: 11:00 AM – 1:00 PM', '24h')).toBe('Monday: 11:00 – 13:00')
expect(convertHoursLine('Friday: 5:00 PM – 2:00 AM', '24h')).toBe('Friday: 17:00 – 02:00')
})
it('FE-PLANNER-HOURS-003: twelve stays twelve in the afternoon and turns to zero after midnight', () => {
expect(convertHoursLine('Sunday: 12:00 – 3:00 PM', '24h')).toBe('Sunday: 12:00 – 15:00')
expect(convertHoursLine('Sunday: 12:00 – 1:00 AM', '24h')).toBe('Sunday: 00:00 – 01:00')
})
it('FE-PLANNER-HOURS-004: a morning range keeps its morning', () => {
expect(convertHoursLine('Tuesday: 8:00 – 11:30 AM', '24h')).toBe('Tuesday: 08:00 – 11:30')
})
it('FE-PLANNER-HOURS-005: a plain hyphen separates the same way the en dash does', () => {
expect(convertHoursLine('Thursday: 1:00-3:00 PM', '24h')).toBe('Thursday: 13:00-15:00')
})
it('FE-PLANNER-HOURS-006: the 12h display and lines without a meridiem are untouched', () => {
expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM', '12h')).toBe('Wednesday: 1:00 – 3:00 PM')
expect(convertHoursLine('Montag: 11:30–23:00', '24h')).toBe('Montag: 11:30–23:00')
expect(convertHoursLine('Saturday: Open 24 hours', '24h')).toBe('Saturday: Open 24 hours')
expect(convertHoursLine('', '24h')).toBe('')
})
it('FE-PLANNER-HOURS-007: 24h lines become 12h, with the German "Uhr" dropped', () => {
expect(convertHoursLine('Montag: 09:00 – 21:00 Uhr', '12h')).toBe('Montag: 9:00 AM – 9:00 PM')
expect(convertHoursLine('Monday: 00:30 – 12:00', '12h')).toBe('Monday: 12:30 AM – 12:00 PM')
})
})
describe('splitHoursLine', () => {
it('FE-PLANNER-HOURS-008: splits on the first colon only, the fullwidth one included', () => {
expect(splitHoursLine('Monday: 09:00-18:00')).toEqual(['Monday', '09:00-18:00'])
expect(splitHoursLine('月曜日:9:00~18:00')).toEqual(['月曜日', '9:00~18:00'])
expect(splitHoursLine('Open 24 hours')).toEqual(['', 'Open 24 hours'])
})
})
describe('isUnknownHoursLine', () => {
it('FE-PLANNER-HOURS-009: a bare question mark is the tag saying nothing', () => {
expect(isUnknownHoursLine(' ? ')).toBe(true)
expect(isUnknownHoursLine('09:00-18:00')).toBe(false)
})
})
@@ -31,8 +31,18 @@ export function convertHoursLine(line: string, timeFormat: string): string {
})
}
if (timeFormat !== '12h' && hasAmPm) {
// Google spells a range that stays in one half of the day with the meridiem on
// the closing time only: "1:00 – 3:00 PM" is one to three in the afternoon. The
// conversion below only sees times that carry their own AM/PM, so the opener
// stayed "1:00" next to a "15:00" (#2412). Hand the closing meridiem back to an
// opener that has none. It fires only where nothing but the dash sits between
// the two times, so "11:00 AM – 1:00 PM" keeps both halves as written.
const paired = line.replace(
/(\d{1,2}:\d{2})(\s*[–—-]\s*)(\d{1,2}:\d{2}\s*)(AM|PM)\b/gi,
(_, open, sep, close, p) => `${open} ${p}${sep}${close}${p}`,
)
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
return paired.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
let hour = Number.parseInt(h)
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
@@ -8,7 +8,7 @@ import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRou
import type { DryPoint } from './roadtripModel'
import type { RefuelSearch } from './useRefuelSearch'
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
import type { RouteSegment } from '../../types'
import type { Reservation, RouteSegment } from '../../types'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
@@ -80,6 +80,31 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
}
}
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
describe('RoadtripSidebar', () => {
/**
* The value the summary head pairs with this label. The same words also name the chips
@@ -585,11 +610,16 @@ describe('RoadtripSidebar', () => {
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
const ferry = leg({ mode: 'ferry' })
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car, a ride keeps its own', () => {
const unknown = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'hovercraft' })] })] })} />)
expect(unknown.container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
unknown.unmount()
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
// A ferry leg is a booked ride since the carrier seams (#2429), not a drive
// whose mode nobody drew an icon for.
const ride = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'ferry' })] })] })} />)
expect(ride.container.querySelector('svg.lucide-sailboat')).toBeInTheDocument()
expect(ride.container.querySelector('svg.lucide-car-front')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
@@ -706,31 +736,6 @@ describe('RoadtripSidebar', () => {
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
*/
describe('the leg the tank runs out on', () => {
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
osm_id: `osm-${name}`,
name,
@@ -1087,3 +1092,241 @@ describe('RoadtripModeSwitch', () => {
expect(onChange).toHaveBeenCalledWith(false)
})
})
describe('RoadtripSidebar with a ride (#2428)', () => {
const terminal = (role: 'departure' | 'arrival') =>
stop({
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
placeId: -70,
lat: role === 'departure' ? 53.63 : 48.35,
lng: role === 'departure' ? 9.99 : 11.78,
time: role === 'departure' ? '12:20' : '14:30',
dwellMinutes: role === 'departure' ? 60 : 0,
legMode: role === 'departure' ? 'flight' : null,
incomingLegMode: role === 'arrival' ? 'flight' : null,
carrier: {
reservationId: 70,
type: 'flight',
role,
title: 'LH 2020',
code: role === 'departure' ? 'HAM' : 'MUC',
at: role === 'departure' ? '13:20' : '14:30',
},
})
const flightDay = () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
terminal('departure'),
terminal('arrival'),
stop({ assignmentId: 2, name: 'Munich' }),
]
return day({
stops,
legs: [leg(), leg({ mode: 'flight', distance: 0, duration: 4200, distanceText: '', durationText: '1 h 10 min' }), leg()],
schedule: {
entries: [
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
{ arrival: '12:20', departure: '13:20', anchored: true, dayOffset: 0 },
{ arrival: '14:30', departure: '14:30', anchored: true, dayOffset: 0 },
{ arrival: '15:30', departure: '15:30', anchored: false, dayOffset: 0 },
],
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes over both terminals with their timetable, and no number', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
expect(screen.getByText('Arrival 14:30')).toBeInTheDocument()
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
// Hamburg is 1 and Munich is 2: the terminals between them take no number, and
// the day header counts two stops.
expect(screen.queryByText('3')).not.toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-050: the ride block opens the booking; the road out of the arrival offers no other ways', () => {
const onOpenBooking = vi.fn()
const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />)
// One control for the whole ride, not one per terminal and one for the band.
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
fireEvent.click(screen.getByText('Hamburg Airport'))
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
expect(onOpenBooking).toHaveBeenCalledTimes(2)
expect(onOpenBooking).toHaveBeenCalledWith(70)
// One shuffle: the road into the departure terminal. Not the ride, not the road out
// of the arrival, whose via would be filed at the terminal's index.
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
it('FE-ROADTRIP-SIDEBAR-051: a ride landing tomorrow leaves its departure as a row of its own, with the road into it', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), terminal('departure')]
const overnight = day({
stops,
legs: [leg()],
schedule: {
entries: [
{ arrival: '18:00', departure: '18:00', anchored: true, dayOffset: 0 },
{ arrival: '21:00', departure: '22:00', anchored: true, dayOffset: 0 },
],
warnings: [],
},
})
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
expect(screen.queryByText('LH 2020 · 1 h 10 min')).not.toBeInTheDocument()
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
/** The card a ride lands on, joined to the card before it by the ride itself. */
const landingDay = (arrivingLeg: RouteSegment) => {
const arrival = terminal('arrival')
return day({
dayId: 2,
dayNumber: 2,
stops: [{ ...arrival, time: '07:00', carrier: { ...arrival.carrier!, at: '07:00' } }, stop({ assignmentId: 2, name: 'Munich' })],
legs: [leg()],
arrivingLeg,
schedule: {
entries: [
{ arrival: '07:00', departure: '07:00', anchored: true, dayOffset: 0 },
{ arrival: '08:00', departure: '08:00', anchored: false, dayOffset: 0 },
],
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-054: the join into the day a ride lands on is the ride, not a car driving no distance', () => {
const flown = leg({ mode: 'flight', distance: 0, duration: 9 * 3600, distanceText: '', durationText: '9 h' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(flown)], totalStops: 1 })} />)
expect(screen.getByText('LH 2020 · 9 h')).toBeInTheDocument()
expect(screen.queryByText(/0 m in 9 h/)).not.toBeInTheDocument()
// Under the booking's own icon, not the car the road out of the airport wears.
const band = screen.getByText('LH 2020 · 9 h').parentElement!
expect(band.querySelector('svg.lucide-plane')).toBeInTheDocument()
expect(band.querySelector('svg.lucide-car-front')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-055: a ride without a timetable still names the booking rather than passing for a hop', () => {
const untimed = leg({ mode: 'flight', distance: 0, duration: 0, distanceText: '', durationText: '' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(untimed)], totalStops: 1 })} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-056: the tank starts afresh after a flight, and the road out of the arrival can be the one it runs out on', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...flightDay(), dryPoints: [dry({ legIndex: 2 })] }], totalStops: 2 })} refuel={search()} />,
)
// Hamburg, the ride block, Munich: the band hangs under the block, where the road
// out of the arrival leaves, and nowhere else.
const rows = container.querySelectorAll('li')
expect(rows).toHaveLength(3)
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
})
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
const desk = (role: 'pickup' | 'return') =>
stop({
assignmentId: role === 'pickup' ? -3000000180 : -3000000181,
name: role === 'pickup' ? 'Sixt Hauptbahnhof' : 'Sixt Airport',
placeId: -90,
time: role === 'pickup' ? '09:00' : '11:30',
dwellMinutes: 0,
carrier: { reservationId: 90, type: 'car', role, title: 'Sixt Hamburg', code: role === 'return' ? 'HAM' : null, at: role === 'pickup' ? '09:00' : '11:30' },
})
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
const rentalDay = () => {
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
return day({
stops,
legs: [leg(), leg(), leg()],
schedule: {
entries: [
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
{ arrival: '09:20', departure: '09:50', anchored: false, dayOffset: 0 },
{ arrival: '10:50', departure: '11:20', anchored: false, dayOffset: 0 },
{ arrival: '11:30', departure: '11:30', anchored: true, dayOffset: 0 },
],
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.queryByText('3')).not.toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
fireEvent.click(screen.getByText('Sixt Airport'))
expect(onOpenBooking).toHaveBeenCalledWith(90)
// Hamburg to Lübeck and Lübeck to the desk leave stored stops; the road out of the
// pick-up desk does not.
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2)
})
it('FE-ROADTRIP-SIDEBAR-057: a tank that empties on the road out of the pick-up desk is offered a fill-up under the desk', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...rentalDay(), dryPoints: [dry({ legIndex: 0 })] }], totalStops: 2 })} refuel={search()} />,
)
const rows = container.querySelectorAll('li')
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[0] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
booking({ id: 12, title: 'Café Niederegger', type: 'restaurant', reservation_time: '2026-10-05T11:30', place_id: 20 }),
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: '2026-10-05T20:00', place_id: 999 }),
booking({ id: 14, title: 'Tomorrow', type: 'event', day_id: 2, place_id: 10 }),
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} canEditBookings />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
expect(screen.getByText('Also booked this day')).toBeInTheDocument()
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
// Another day's booking is not this day's, and the night is the stay, not a chip.
expect(screen.queryByText('Tomorrow')).not.toBeInTheDocument()
expect(screen.queryByText('Hotel Hafen')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpenBooking).toHaveBeenCalledWith(11)
// The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
booking({ id: 12, title: 'Taxi zum Hafen', type: 'taxi', reservation_time: '2026-10-05T18:30', assignment_id: 1 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
// The table is there to read, not a button that does nothing; the taxi is a button.
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpenBooking).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi zum Hafen'))
expect(onOpenBooking).toHaveBeenCalledWith(12)
})
})
@@ -1,6 +1,6 @@
import ChargingInfo from './ChargingInfo'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useState } from 'react'
import React, { useMemo, useState } from 'react'
import {
CarFront, Footprints, Bike, Zap, AlertTriangle,
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
@@ -17,12 +17,13 @@ import { formatDistance } from '../../utils/units'
import { formatDate, formatClockTime } from '../../utils/formatters'
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
import { legReroutable } from './roadtripRowModel'
import { destinationCount, isHop, legReroutable } from './roadtripRowModel'
import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
import { useVehicleRange } from './useVehicleRange'
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
import { isCarrierMode, type CarrierTerminal } from '@trek/shared/roadtrip'
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { SpillMark } from './nightSpill'
import { dayColor } from './dayColors'
@@ -31,6 +32,9 @@ import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
import { bookingOpens, carrierIcon, rideText, terminalLine } from './carrierRide'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
@@ -102,6 +106,22 @@ interface RoadtripSidebarProps {
collapsedDayIds?: Set<number>
/** Absent leaves every card open and its header unclickable. */
onToggleDay?: (dayId: number) => void
/**
* The trip's bookings, for the rail to hang under the stops they are for (#2428).
* Absent draws the chain without them.
*/
reservations?: Reservation[]
/**
* Opens a booking: the ride a terminal stands for, the hire car a desk belongs to,
* the table or the tickets under a stop. Absent leaves them all as plain text.
*/
onOpenBooking?: (reservationId: number) => void
/**
* Whether the reader may open a table's or a ticket's editor. A transport has a detail
* view anybody may look at; the other chips have only their editor, and without the
* right to it they stay plain rather than being a button that does nothing (#2012).
*/
canEditBookings?: boolean
}
const MODE_ICON: Record<string, LucideIcon> = {
@@ -670,8 +690,10 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
)
}
function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: {
leg: RouteSegment | undefined
/** The booking, when the leg is a ride and not a road: the join into the day it lands on. */
carrier?: CarrierTerminal
/** Asks for other ways of driving this leg. Absent means the route is not editable. */
onAskAlternatives?: () => void
alternativesOpen?: boolean
@@ -679,19 +701,37 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const mode = leg?.mode ?? 'driving'
const Icon = mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
// A ride reads as the ride block's pill does: the booking and its minutes under the
// booking's icon. Through the road branches it was a car driving no distance for nine
// hours, and a ride without a timetable was short enough to pass for a hop.
const ride = isCarrierMode(mode)
const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
// A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is
// nothing to say about it and no other way to drive it.
if (!ride && isHop(leg)) {
return (
<div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex min-h-[10px] flex-col items-center" aria-hidden>
<span className="flex-1" style={RAIL_DASH} />
</span>
<span />
</div>
)
}
// The band's contents, shared by the clickable and the read-only shape so the two can
// never drift apart in what they say.
const band = (
<>
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
{leg
? t('roadtrip.leg.driveText', {
distance: formatDistance(leg.distance / 1000, distanceUnit),
time: formatDurationShort(leg.duration),
})
: t('roadtrip.leg.pending')}
{!leg
? t('roadtrip.leg.pending')
: ride
? rideText(carrier, leg)
: t('roadtrip.leg.driveText', {
distance: formatDistance(leg.distance / 1000, distanceUnit),
time: formatDurationShort(leg.duration),
})}
</span>
</>
)
@@ -748,6 +788,185 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
)
}
/**
* One end of a ride: the airport, station or port the drive stops at or resumes from.
*
* No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
* and cannot be moved or turned into anything, it is where the booking puts the
* traveller. What it shows is the booking's own clock under its name, and on the right
* the time the chain wants the traveller there, which for a departure is a check-in ahead
* of the timetable. The whole row opens the booking.
*/
function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
continues: boolean
starts?: boolean
onOpen?: () => void
}): React.ReactElement {
const { t } = useTranslation()
const Icon = carrierIcon(stop.carrier!.type)
return (
<button
type="button"
onClick={onOpen}
disabled={!onOpen}
aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
style={RAIL_GRID}
>
<span className="flex flex-col items-center">
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
<Icon size={13} strokeWidth={2} aria-hidden />
</span>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
</span>
<span className="min-w-0 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
<RideEnd stop={stop} entry={entry} late={late} />
</span>
</button>
)
}
/**
* One end of a ride: the terminal's name and code, the timetable's clock under it, and
* on the right the time the chain has the traveller there. Inside the ride's block for
* a same-day ride, and the whole of a lone terminal's row.
*/
function RideEnd({ stop, entry, late }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const line = terminalLine(stop.carrier!, t, is12h)
return (
<span className="flex min-w-0 items-start gap-2">
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
<span className="min-w-0 break-words">{stop.name}</span>
{stop.carrier!.code ? (
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{stop.carrier!.code}</span>
) : null}
</span>
<span className="flex flex-wrap items-center gap-1">
{line ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{line}</span> : null}
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
</span>
)
}
/**
* A ride that leaves and lands on the same day, as one thing in the chain: the booking
* at the top, the terminal it leaves from, the minutes in the air, the terminal it lands
* at. One block on one disc rather than three rows, because it IS one thing, a flight,
* and three rows read as three places the day went to. The whole block opens the
* booking; the road resumes below it as usual.
*/
function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
departure: RoadtripStop
arrival: RoadtripStop
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
late: [ScheduleWarning[], ScheduleWarning[]]
seg: RouteSegment | undefined
continues: boolean
starts?: boolean
onOpen?: () => void
}): React.ReactElement {
const { t } = useTranslation()
const carrier = departure.carrier!
const Icon = carrierIcon(carrier.type)
return (
<button
type="button"
onClick={onOpen}
disabled={!onOpen}
aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
style={RAIL_GRID}
>
<span className="flex flex-col items-center">
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
<Icon size={13} strokeWidth={2} aria-hidden />
</span>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
</span>
<span className="my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border border-edge-faint bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected">
<span className="flex min-w-0 items-center gap-1.5 text-content-muted">
<Icon size={11} strokeWidth={1.8} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate font-geist font-medium uppercase tracking-[0.09em]" style={{ fontSize: FS.label }}>
{rideText(carrier, seg)}
</span>
</span>
<RideEnd stop={departure} entry={entries[0]} late={late[0]} />
<RideEnd stop={arrival} entry={entries[1]} late={late[1]} />
</span>
</button>
)
}
/**
* The bookings a stop carries, under its row: the table, the tickets, the tour, each a
* chip with the booking panel's icon, its name and the clock it starts at. Chips because
* a booking is a fact about the stop, not a stop of its own: the count, the numbering
* and the drive are none the different for it. Each opens its booking.
*
* Under the stop's button rather than inside it, because a button cannot hold buttons.
* The rail keeps its line through the row so the chain reads unbroken.
*/
function BookingChips({ bookings, continues, canEdit, onOpen }: {
bookings: Reservation[]
continues: boolean
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
canEdit: boolean
onOpen?: (reservationId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
return (
<div className="grid" style={RAIL_GRID}>
<span className="flex flex-col items-center" aria-hidden>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
</span>
<div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
{bookings.map(r => {
const Icon = bookingIcon(r.type)
const clock = bookingClock(r)
const body = (
<>
<Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">{r.title}</span>
{clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
</>
)
const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
return onOpen && bookingOpens(r, canEdit) ? (
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
<button
type="button"
onClick={() => onOpen(r.id)}
className={`${chip} transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
style={{ fontSize: FS.meta }}
>
{body}
</button>
</Tooltip>
) : (
<span key={r.id} className={chip} style={{ fontSize: FS.meta }}>{body}</span>
)
})}
</div>
</div>
)
}
/**
* A charger, a filling station or a rest stop — a stop that interrupts the drive.
*
@@ -1289,7 +1508,7 @@ function SpillBlock({ spill, children }: {
* move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`.
*/
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay
/** Folded down to the header, and off the map with it. */
@@ -1303,6 +1522,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
drag: DragState
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
openAlternatives?: RoadtripSidebarProps['openAlternatives']
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
canEditBookings?: boolean
reservations?: Reservation[]
/** A day's place in the trip by its id, so a booking spanning days is seen on the days between. */
dayOrder?: (dayId: number) => number | null
onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
@@ -1319,6 +1543,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
// Which disc the picker hangs under, and for which stop. One at a time: two open
// popovers over the same rail is two answers to one question.
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const bookings = useMemo(() => dayBookings(day, reservations ?? [], dayOrder), [day, reservations, dayOrder])
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -1341,27 +1566,102 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is
* still stored on the day it set off from (`nightSpill.ts`).
*/
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => {
const latenessAt = (i: number): ScheduleWarning[] =>
day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
/**
* The band for the tank running out on the leg leaving `legIndex`, whichever row owns
* that leg: a stop, a night, a terminal. Only once the day has finished routing: the
* warnings are republished after every routing task and the early ones are wrong, so
* an offer that appears and moves while the trip loads reads as a fault.
*
* Keyed by the card's day, not the stop's stored one. This is the key the open search
* is filed under, and the hook is asked with the same number. A band inside a borrowed
* stretch had them differ, so the results arrived, drew on the map, and the band they
* belonged to never opened.
*/
const refuelBandsFor = (legIndex: number): React.ReactElement[] | null =>
refuel && !loading
? (day.dryPoints ?? []).filter(dry => dry.legIndex === legIndex).map(dry => (
<RefuelBand
key={`dry-${dry.legIndex}`}
dry={dry}
dayId={day.dayId}
refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
/>
))
: null
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
if (stop.automaticNight) return (
<li key={stop.assignmentId}>
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
{i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null}
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => (
<RefuelBand key={`dry-${dry.legIndex}`} dry={dry} dayId={day.dayId} refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
)) : null}
{refuelBandsFor(i)}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
</li>
)
const service = isServiceStopType(stop.stopType)
if (!service) counted += 1
// Every finding at this index is read on its own. Taking the first match let an
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
// can be late for the time it is reached and the time it is left at both at once.
const lateness = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
const lateness = latenessAt(i)
// A terminal or a hire car's desk: not draggable, not numbered, not a place. A ride
// that lands on the day it left is one block from its departure to its arrival, and
// the arrival index draws nothing of its own. A lone terminal (a ride landing
// tomorrow, a desk) is a row. Behind either the road goes on as usual, without other
// ways to drive it (`legReroutable`).
if (stop.carrier) {
const prev = day.stops[i - 1]
const next = day.stops[i + 1]
const sameRide = (other: RoadtripStop | undefined): boolean => other?.carrier?.reservationId === stop.carrier!.reservationId
if (stop.carrier.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return null
const ride = stop.carrier.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)
const open = onOpenBooking ? () => onOpenBooking(stop.carrier!.reservationId) : undefined
// The index the road leaves from: the arrival's when the block holds both ends.
const tail = ride ? i + 1 : i
const after = day.stops[tail + 1]
return (
<li key={stop.assignmentId}>
{ride ? (
<RideBlock
departure={stop}
arrival={next!}
entries={[day.schedule.entries[i], day.schedule.entries[i + 1]]}
late={[lateness, latenessAt(i + 1)]}
seg={day.legs[i]}
continues={tail < last}
starts={i === 0}
onOpen={open}
/>
) : (
<TerminalStop
stop={stop}
entry={day.schedule.entries[i]}
late={lateness}
continues={i < last}
starts={i === 0}
onOpen={open}
/>
)}
{tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? (
/* The row the road leaves from carries the booking whenever that road is a
ride itself (a departure terminal seated mid-day), and the band needs it
to say more than a bare icon. */
<DriveBand leg={day.legs[tail]} carrier={day.stops[tail]?.carrier} />
) : null}
{/* The tank starts afresh after a flight and full at a pick-up desk, and the
first road out of either can be the one that empties it. */}
{refuelBandsFor(tail)}
{tail < last ? (day.legVias[tail] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
</li>
)
}
const service = isServiceStopType(stop.stopType)
if (!service) counted += 1
const ownDay = stop.ownerDayId
const ownIndex = stop.ownerIndex
return (
@@ -1426,6 +1726,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/>
)}
{bookings.atStop.has(i) ? (
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
) : null}
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand
leg={day.legs[i]}
@@ -1433,28 +1736,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/>
) : null}
{/* Only on the leg the fuel actually runs out on, and only once the day has
finished routing: the warnings are republished after every routing task
and the early ones are wrong, so an offer that appears and moves while
the trip loads reads as a fault. */}
{refuel && !loading
? (day.dryPoints ?? [])
.filter(dry => dry.legIndex === i)
.map(dry => (
<RefuelBand
key={`dry-${dry.legIndex}`}
dry={dry}
// The card's day, not the stop's stored one. This is the key the open
// search is filed under, and the hook is asked with the same number —
// a band inside a borrowed stretch had them differ, so the results
// arrived, drew on the map, and the band they belonged to never opened.
dayId={day.dayId}
refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined}
/>
))
: null}
{refuelBandsFor(i)}
{/* After the band, because a plugin halt happens on the drive it describes
rather than before setting off. */}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
@@ -1471,18 +1753,15 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
* Built rather than mapped straight through, because a spill is drawn inside a block of
* its own and a block cannot be opened halfway down a `map`.
*/
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => (
<React.Fragment key={stop.assignmentId}>
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => (
<li key={`inbound-${i}`}>
<RefuelBand dry={dry} dayId={day.dayId} refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
</li>
)) : null}
{renderStopContent(stop, i)}
</React.Fragment>
)
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
const inbound = refuelBandsFor(-i - 1)
return (
<React.Fragment key={stop.assignmentId}>
{inbound?.length ? <li key={`inbound-${i}`}>{inbound}</li> : null}
{renderStopContent(stop, i)}
</React.Fragment>
)
}
const runs: { spill: SpillMark | null; from: number; to: number }[] = []
{
let at = 0
@@ -1553,12 +1832,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
{formatDate(day.date, language)}
</time>
) : null}
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})}
</span>
{/* A day that is only the night booked for it has no drive to sum up and no
stop to count; the hotel row below says everything it has to say. */}
{day.legs.length > 0 ? (
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})}
</span>
) : null}
{day.dayWarning ? (
<Tooltip label={t('roadtrip.limit.hint')}>
<span className={`${DAY_BADGE} gap-1 bg-warning-soft text-warning`} style={{ fontSize: FS.label }}>
@@ -1569,9 +1852,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
</span>
</Tooltip>
) : null}
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
</span>
{day.legs.length > 0 ? (
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: destinationCount(day) })}
</span>
) : null}
{/* The other half of "where possible". The setting is a weighting, so a day
with no untolled crossing comes back on the toll road — and the only thing
worse than not avoiding it is not avoiding it silently, which reads as the
@@ -1619,7 +1904,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
happens. Without it a morning that starts at 08:11 after a stay that ended at
08:00 looks like eleven minutes went missing. A day whose first stop crossed
over instead gets that road under its own block, so this is left out there. */}
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} /> : null}
{/* A day that opens on an arrival terminal is joined by the ride, and the
terminal carries the booking the band names. */}
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} carrier={day.stops[0]?.carrier} /> : null}
{runs.map(run => (
run.spill ? (
<SpillBlock key={`spill-${run.from}`} spill={run.spill}>
@@ -1631,6 +1918,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
</React.Fragment>
)
))}
{/* Booked on this day, for no stop the day drives to: the rail has nowhere
better to put them and leaving them out would make the mode look like it
lost them. */}
{bookings.loose.length ? (
<li className="mt-1.5">
<div className="grid" style={RAIL_GRID}>
<span aria-hidden />
<span className={`${STAT_LABEL} px-1.5`} style={{ fontSize: FS.micro }}>{t('roadtrip.bookings.loose')}</span>
</div>
<BookingChips bookings={bookings.loose} continues={false} canEdit={!!canEditBookings} onOpen={onOpenBooking} />
</li>
) : null}
</ol>
{picking ? (
<StopKindPicker
@@ -1731,7 +2030,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
* them is a pill just for being a number.
*/
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
routes, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement {
@@ -1741,6 +2040,13 @@ export default function RoadtripSidebar({
const [from, setFrom] = React.useState<DragState['from']>(null)
const [dropAt, setDropAt] = React.useState<DragState['dropAt']>(null)
const drag: DragState = { from, setFrom, dropAt, setDropAt }
// Where each day stands in the trip, so a booking that spans several days is listed on
// the days between its two ends as well, the way the day plan lists a three-day tour.
// Quiet days count: a span can run straight over one.
const dayOrder = useMemo(() => {
const numbers = new Map([...routes.days, ...routes.quietDays].map(d => [d.dayId, d.dayNumber]))
return (dayId: number): number | null => numbers.get(dayId) ?? null
}, [routes.days, routes.quietDays])
// Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
if (routes.days.length === 0) {
@@ -1789,6 +2095,10 @@ export default function RoadtripSidebar({
drag={drag}
onAskAlternatives={onAskAlternatives}
openAlternatives={openAlternatives}
onOpenBooking={onOpenBooking}
canEditBookings={canEditBookings}
reservations={reservations}
dayOrder={dayOrder}
onEditStay={onEditStay}
onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill}
@@ -0,0 +1,60 @@
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
import { formatDurationShort } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
import type { Reservation, TranslationFn } from '../../types'
/**
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
* the phone chain (#2428), and which of the bookings under a stop open there.
*
* One module for both shells, because the two would otherwise carry the same icon table
* and the same three sentences each, and the duplication budget does not stretch to that.
* The icons are the booking panel's for the same types, so a flight looks like a flight
* wherever the trip shows it.
*/
const CARRIER_ICON: Record<string, LucideIcon> = {
flight: Plane,
train: Train,
ferry: Sailboat,
cruise: Ship,
bus: Bus,
car: Car,
}
export function carrierIcon(type: string): LucideIcon {
return CARRIER_ICON[type] ?? Plane
}
/**
* The line under a terminal's name: the timetable's clock at it, in the reader's own
* clock format. Nothing when the booking names no time.
*/
export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null {
if (!carrier.at) return null
const time = formatClockTime(carrier.at, is12h)
return t(`roadtrip.ride.${carrier.role}`, { time })
}
/**
* What the ride's pill says: the booking, then how long it takes when the timetable gives
* both ends. A ride with no minutes is still the booking, and says so without a duration.
* A ride leg met without its booking is just the minutes.
*/
export function rideText(carrier: Pick<CarrierTerminal, 'title'> | undefined, seg: RouteSegment | undefined): string {
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
if (!carrier) return duration
return duration ? `${carrier.title} · ${duration}` : carrier.title
}
/**
* Whether a booking chip opens for this reader. A transport has a detail view anybody
* may look at; a table or a ticket has only its editor, the split the planner page makes
* when it opens one. Without the right to edit bookings the chip stays a plain chip,
* the way the place inspector's booking strip stays inert (#2012).
*/
export function bookingOpens(booking: Pick<Reservation, 'type'>, canEditBookings: boolean): boolean {
return TRANSPORT_TYPES.has(booking.type) || canEditBookings
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip'
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround, rideGaps, riddenRanges, inRiddenRange, drivenPieces } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit, KmRange, RideGap } from '@trek/shared/roadtrip'
@@ -15,7 +15,7 @@ import {
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-044
// FE-RTROW-001 to FE-RTROW-055
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -520,3 +520,132 @@ describe('legReroutable', () => {
expect(legReroutable(extra, -1)).toBe(false)
})
})
describe('roadtripRows with a ride (#2428)', () => {
const terminal = (name: string, role: 'departure' | 'arrival', at: string | null = '13:20') =>
stop(name, {
assignmentId: role === 'departure' ? -3000000014 : -3000000015,
placeId: -7,
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: role === 'departure' ? 'MUC' : 'HAM', at },
legMode: role === 'departure' ? 'flight' : null,
incomingLegMode: role === 'arrival' ? 'flight' : null,
})
const ride: RouteSegment = { ...seg(9), distance: 0, duration: 4200, mode: 'flight', distanceText: '', durationText: '1 h 10 min' }
const flightDay = () =>
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30'), stop('Hotel')], {
legs: [seg(0), ride, seg(2)],
})
it('FE-RTROW-045: a same-day ride is one row holding both terminals, unnumbered, and the numbering skips them', () => {
const rows = roadtripRows(flightDay())
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([
['Bremen', 1],
['Hotel', 2],
])
const rideRow = rows.find(r => r.kind === 'ride')
expect(rideRow?.kind === 'ride' && [rideRow.departure.stop.name, rideRow.departure.number, rideRow.arrival.stop.name, rideRow.arrival.number])
.toEqual(['Munich Airport', null, 'Hamburg Airport', null])
expect(destinationCount(flightDay())).toBe(2)
})
it('FE-RTROW-046: the ride row stands where the departure stood and carries the booking; the road out of the arrival follows it as a leg', () => {
const rows = roadtripRows(flightDay())
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'ride', 'leg', 'stop'])
const rideRow = rows[2]
expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020')
expect(rideRow.kind === 'ride' && rideRow.seg).toBe(ride)
expect(rideRow.kind === 'ride' && rideRow.arrival.time).toBe(rideRow.kind === 'ride' ? rideRow.arrival.entry?.arrival ?? null : null)
// The leg after the ride is the arrival's, at the arrival's index.
expect(rows[3]).toMatchObject({ kind: 'leg', index: 2 })
})
it('FE-RTROW-049: a ride landing tomorrow leaves its departure as a lone stop row, and the arrival opens the next day as one', () => {
const rows = roadtripRows(day([stop('Bremen'), terminal('Munich Airport', 'departure')], { legs: [seg(0)] }))
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
const landing = roadtripRows(day([terminal('Hamburg Airport', 'arrival', '07:00'), stop('Hotel')], { legs: [seg(0)] }))
expect(landing.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
expect(stopRows(landing).map(r => [r.stop.name, r.number])).toEqual([['Hamburg Airport', null], ['Hotel', 1]])
})
it('FE-RTROW-051: a hop, the desk beside the terminal, is no leg row and is offered no other ways', () => {
const hop: RouteSegment = { ...seg(0), distance: 120, duration: 45, distanceText: '0 km', durationText: '0 min' }
const d = day([stop('Sixt Airport'), stop('Hamburg Airport'), stop('Hotel')], { legs: [hop, seg(1)] })
expect(roadtripRows(d).map(r => r.kind)).toEqual(['stop', 'stop', 'leg', 'stop'])
expect(legReroutable(d, 0)).toBe(false)
expect(legReroutable(d, 1)).toBe(true)
// A note a plugin attached keeps the row, however short the hop.
const noted = day([stop('A'), stop('B')], { legs: [{ ...hop, noteText: '10 min charge' }] })
expect(roadtripRows(noted).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
})
it('FE-RTROW-050: a hire car\'s desks are unnumbered stop rows the road runs through', () => {
const desk = (name: string, role: 'pickup' | 'return') =>
stop(name, {
assignmentId: role === 'pickup' ? -3000000018 : -3000000019,
placeId: -9,
carrier: { reservationId: 9, type: 'car', role, title: 'Sixt', code: null, at: '09:00' },
})
const rows = roadtripRows(day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] }))
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop'])
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([['Sixt Hbf', null], ['Bremen', 1], ['Sixt Airport', null]])
const d = day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] })
expect(legReroutable(d, 0)).toBe(false)
expect(legReroutable(d, 1)).toBe(true)
})
it('FE-RTROW-047: the road into a departure terminal can be offered other ways, nothing leaving a terminal can', () => {
const d = flightDay()
expect(legReroutable(d, 0)).toBe(true)
expect(legReroutable(d, 1)).toBe(false)
expect(legReroutable(d, 2)).toBe(false)
})
it('FE-RTROW-048: a terminal never answers for a place, however its id reads', () => {
expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
})
/** Bremen at 09:00, the airport at 12:20 for a 13:20 flight, landing at 14:30, nothing after. */
const endsOnRide = () =>
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [seg(0), ride],
schedule: { entries: [entry('09:00'), entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
/** The flight first, the hotel after it. */
const opensOnRide = () =>
day([terminal('Munich Airport', 'departure', '06:00'), terminal('Hamburg Airport', 'arrival', '07:10'), stop('Hotel')], {
legs: [ride, seg(1)],
schedule: { entries: [entry('05:00', { departure: '06:00' }), entry('07:10'), entry('09:00')], warnings: [] },
})
it('FE-RTROW-052: a day that ends on a ride arrives at the arrival terminal, one that opens on a ride starts at the departure', () => {
// The head card printed the clock of the stop before the flight as where the day ends,
// while the ride block right below it printed the landing.
expect(stageClocks(roadtripRows(endsOnRide()))).toEqual({ start: '09:00', arrive: '14:30' })
expect(stageClocks(roadtripRows(opensOnRide()))).toEqual({ start: '05:00', arrive: '09:00' })
})
it('FE-RTROW-053: a day that is nothing but a ride still has both clocks', () => {
const rideOnly = day([terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [ride],
schedule: { entries: [entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
expect(stageClocks(roadtripRows(rideOnly))).toEqual({ start: '12:20', arrive: '14:30' })
})
it('FE-RTROW-054: up next names the departure terminal before its check-in, and the arrival after take-off', () => {
// A lone terminal (a ride landing tomorrow) was already a candidate; the terminals
// inside a same-day ride were not, so the countdown skipped the airport.
expect(upNextStop(endsOnRide(), 10 * 60, true)?.row.stop.name).toBe('Munich Airport')
expect(upNextStop(endsOnRide(), 10 * 60, true)?.minutesUntil).toBe(140)
expect(upNextStop(endsOnRide(), 13 * 60 + 30, true)?.row.stop.name).toBe('Hamburg Airport')
expect(upNextStop(endsOnRide(), 15 * 60 + 1, true)?.minutesUntil).toBe(-31)
})
it('FE-RTROW-055: a stage that ends on a ride ends at its arrival terminal', () => {
const end = stageEnd(roadtripRows(endsOnRide()))
expect(end?.stop.name).toBe('Hamburg Airport')
expect(end?.time).toBe('14:30')
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
})
})
@@ -1,5 +1,5 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import type { CarrierTerminal, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import { readStay } from './stayReading'
/**
@@ -21,13 +21,19 @@ export type RoadtripRow =
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
| StopRow
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
/**
* A ride that leaves and lands on the day (#2428), as one row: the booking with both
* its terminals inside it, in place of two stop rows and the leg between them. The
* booking's minutes, no distance, and the road goes on from `arrival`.
*/
| { kind: 'ride'; index: number; carrier: CarrierTerminal; seg: RouteSegment | undefined; departure: StopRow; arrival: StopRow }
| { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
export interface StopRow {
kind: 'stop'
stop: RoadtripStop
/** Position in the day's numbering, or null for a service stop and an automatic night. */
/** Position in the day's numbering, or null for a service stop, a terminal and an automatic night. */
number: number | null
service: boolean
entry: ScheduleEntry | undefined
@@ -132,6 +138,20 @@ function isStationary(seg: RouteSegment | undefined): boolean {
&& seg.from[1] === seg.to[1]
}
/**
* A leg too short to be a drive: the hire desk beside the terminal, the car park beside
* the gate. Under 150 m and two minutes it is a hop, and a pill reading "0 km in 0 min"
* under it is noise where the eye wants the next stop. The line still joins the two,
* and a note a plugin attached is still worth the row.
*/
export function isHop(seg: RouteSegment | undefined): boolean {
return !!seg
&& Number.isFinite(seg.distance)
&& seg.distance < 150
&& seg.duration < 120
&& !seg.noteText
}
export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
const rows: RoadtripRow[] = []
const spills = day.spills ?? []
@@ -148,6 +168,13 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
})
}
const next = day.stops[i + 1]
const prev = day.stops[i - 1]
const sameRide = (other: RoadtripStop | undefined): boolean =>
!!stop.carrier && other?.carrier?.reservationId === stop.carrier.reservationId
// The arrival of a same-day ride is inside the ride row its departure made.
if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return
const automatic = !!stop.automaticNight
if (automatic) {
rows.push({
@@ -155,22 +182,44 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
time: day.schedule.entries[i]?.arrival ?? null,
})
} else if (stop.carrier?.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)) {
rows.push({
kind: 'ride',
index: i,
carrier: stop.carrier,
seg: day.legs[i],
departure: stopRow(stop, i, null, day.schedule, day.driveWarnings),
arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings),
})
// The road out of the arrival belongs to this row too; the pass below only
// looks at the index it is on.
pushLeg(i + 1)
return
} else {
if (!isServiceStopType(stop.stopType)) number += 1
rows.push(stopRow(stop, i, isServiceStopType(stop.stopType) ? null : number, day.schedule, day.driveWarnings))
// A terminal or a hire car's desk is where the drive stops or resumes, not a
// place the trip is for: it carries no number, the way a service stop carries none.
const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
if (!unnumbered) number += 1
rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings))
}
// The leg AFTER this stop, plus the dry point that falls on it. Both belong
// between two stops, so they are emitted here rather than in their own pass.
pushLeg(i)
})
// The leg AFTER stop `i`, plus the dry point that falls on it. Both belong between
// two stops, so they are emitted with the stop before them rather than in a pass of
// their own.
function pushLeg(i: number): void {
const stop = day.stops[i]!
const seg = day.legs[i]
if (i < day.stops.length - 1 && !isStationary(seg)) {
if (i < day.stops.length - 1 && !isStationary(seg) && !isHop(seg)) {
rows.push({ kind: 'leg', index: i, seg, mode: day.stops[i + 1]?.incomingLegMode ?? stop.legMode ?? null })
const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
if (dry) {
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
}
}
})
}
return rows
}
@@ -191,13 +240,28 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
return index >= 0
&& index < day.stops.length - 1
&& !!day.legs[index]
&& !isHop(day.legs[index])
&& !day.stops[index].automaticNight
&& !day.stops[index + 1].automaticNight
// A leg leaving a terminal is either the ride, which has no other way, or the road
// out of an arrival, whose via would be filed at the terminal's index, which is the
// index of the stop after it. The road INTO a departure terminal leaves a stored
// stop and can be offered other ways like any other.
&& !day.stops[index].carrier
}
/** Stops that carry a number, for a count that agrees with the numbering above. */
export function destinationCount(day: RoadtripDay): number {
return day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length
return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
}
/**
* The rows with each ride opened up into its two terminals, in place. A ride row holds
* them instead of two stop rows of their own, so a reader walking the rows for stops
* has to look inside it, or a day that ends on a flight ends at the stop before it.
*/
function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
return rows.flatMap(row => (row.kind === 'ride' ? [row.departure, row.arrival] : [row]))
}
/**
@@ -229,7 +293,7 @@ export function destinationCount(day: RoadtripDay): number {
export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } {
let start: string | null = null
let arrive: string | null = null
for (const row of rows) {
for (const row of unfoldRides(rows)) {
if (row.kind === 'stop' && row.time) {
start ??= row.time
arrive = row.time
@@ -241,7 +305,8 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
}
/**
* The stop a stage ends on: the last one the chain draws, a service stop included.
* The stop a stage ends on: the last one the chain draws, a service stop or the arrival
* terminal of a ride included.
*
* The map half names it in its bar and opens it on a tap, so the name, the clock beside it
* and the sheet the tap brings up all come off this one row and cannot name three different
@@ -253,8 +318,9 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
* There is nothing for a tap to open then, and the bar says so by not being a button.
*/
export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i]
const unfolded = unfoldRides(rows)
for (let i = unfolded.length - 1; i >= 0; i--) {
const row = unfolded[i]
if (row.kind === 'stop') return row
}
return null
@@ -276,7 +342,7 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
*/
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null {
for (const day of days) {
const stop = day.stops.find(s => !s.automaticNight && s.placeId === placeId)
const stop = day.stops.find(s => !s.automaticNight && !s.carrier && s.placeId === placeId)
if (stop) return stop
}
return null
@@ -300,7 +366,7 @@ export function upNextStop(
isToday: boolean,
): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return null
const timed = roadtripRows(day)
const timed = unfoldRides(roadtripRows(day))
.filter((r): r is StopRow => r.kind === 'stop' && !r.service)
.map(row => ({ row, at: clockMinutes(row.time) }))
.filter((x): x is { row: StopRow; at: number } => x.at != null)
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { Car, Ticket, Utensils } from 'lucide-react'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
const day = {
dayId: 1,
stops: [
{ assignmentId: -3000000140, placeId: -70, carrier: { reservationId: 70, type: 'flight', role: 'arrival' as const, title: 'LH', code: null, at: null } },
{ assignmentId: 11, placeId: 110 },
{ assignmentId: -2000000001, placeId: 110, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } as never },
{ assignmentId: 12, placeId: 120 },
],
}
describe('dayBookings (#2428)', () => {
it('FE-STOPBOOKINGS-001: a booking hangs under the stop it is pinned to, or under the stop at its place on its day, in clock order', () => {
const { atStop, loose } = dayBookings(day, [
booking({ id: 3, title: 'Late', reservation_time: '2026-10-05T20:00', assignment_id: 11 }),
booking({ id: 2, title: 'Early', reservation_time: '2026-10-05T11:00', place_id: 110 }),
booking({ id: 4, title: 'Untimed', place_id: 110 }),
booking({ id: 5, title: 'Ticket', type: 'event', place_id: 120 }),
])
expect([...atStop.keys()]).toEqual([1, 3])
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Early', 'Late', 'Untimed'])
expect(atStop.get(3)!.map(r => r.title)).toEqual(['Ticket'])
expect(loose).toEqual([])
})
it('FE-STOPBOOKINGS-002: a terminal and an automatic night hold nothing, even at the same place; a pinned booking needs no day', () => {
// The night marker shares the hotel's place id; the booking still lands on the stop.
const { atStop } = dayBookings(day, [booking({ id: 6, place_id: 110, day_id: 1 })])
expect([...atStop.keys()]).toEqual([1])
// Pinned to the assignment: on the stop whatever day the row names.
const pinned = dayBookings(day, [booking({ id: 7, assignment_id: 12, day_id: 9 })])
expect([...pinned.atStop.keys()]).toEqual([3])
// A booking at the flight's negative place id is nobody's.
expect(dayBookings(day, [booking({ id: 8, place_id: -70 })]).loose).toHaveLength(1)
})
it('FE-STOPBOOKINGS-003: the night, the hire car and the ride are drawn elsewhere; another day\'s booking is not this day\'s; a span ends on this day', () => {
const { atStop, loose } = dayBookings(day, [
booking({ id: 1, type: 'hotel', place_id: 110 }),
booking({ id: 2, type: 'car', place_id: 110 }),
booking({ id: 3, type: 'flight', place_id: 110 }),
booking({ id: 4, type: 'train', place_id: 110 }),
booking({ id: 5, title: 'Tomorrow', day_id: 2, place_id: 110 }),
booking({ id: 6, title: 'Tour ends here', type: 'tour', day_id: 0, end_day_id: 1 }),
])
expect(atStop.size).toBe(0)
expect(loose.map(r => r.title)).toEqual(['Tour ends here'])
expect(dayBookings(day, [])).toEqual({ atStop: new Map(), loose: [] })
})
it('FE-STOPBOOKINGS-005: a booking spanning several days is on the days between as well, unless its type hides there', () => {
// Day ids out of order on purpose: the span is read by the days' position, not by id.
const order = (dayId: number) => ({ 5: 0, 1: 1, 3: 2 } as Record<number, number>)[dayId] ?? null
const tour = booking({ id: 1, title: 'Three-day tour', type: 'tour', day_id: 5, end_day_id: 3 })
const parked = booking({ id: 2, title: 'Parked', type: 'parking', day_id: 5, end_day_id: 3 })
const dinner = booking({ id: 3, title: 'Dinner', day_id: 5, end_day_id: 3, place_id: 110 })
const { atStop, loose } = dayBookings(day, [tour, parked, dinner], order)
expect(loose.map(r => r.title)).toEqual(['Three-day tour'])
// On the day between, a booking for a place the day drives to still hangs under it.
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Dinner'])
// Its first and last day list the parked car as before.
expect(dayBookings({ ...day, dayId: 3 }, [parked], order).loose).toHaveLength(1)
// Without a day order, or for a day the order does not know, only the two ends count.
expect(dayBookings(day, [tour])).toEqual({ atStop: new Map(), loose: [] })
expect(dayBookings({ ...day, dayId: 9 }, [tour], order)).toEqual({ atStop: new Map(), loose: [] })
})
it('FE-STOPBOOKINGS-004: the chip wears the booking panel\'s icon and the clock the booking starts at', () => {
expect(bookingIcon('restaurant')).toBe(Utensils)
expect(bookingIcon('car')).toBe(Car)
expect(bookingIcon('nonsense')).toBe(Ticket)
expect(bookingClock({ reservation_time: '2026-10-05T19:30' })).toBe('19:30')
expect(bookingClock({ reservation_time: null })).toBeNull()
})
})
@@ -0,0 +1,101 @@
import { Ticket, type LucideIcon } from 'lucide-react'
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
import { hidesOnMiddleDay } from '../../utils/dayMerge'
import { splitReservationDateTime } from '../../utils/formatters'
import type { Reservation } from '../../types'
import type { RoadtripStop } from '@trek/shared/roadtrip'
/**
* The bookings a day carries besides its nights, its rides and its hire car: the table,
* the tickets, the tour. On the road trip each hangs under the stop it is for, and one
* that is for no stop on the day is listed under the day, so nothing booked goes unseen
* in this mode.
*
* A booking is a stop's when it is pinned to the stop's assignment, or, failing that,
* when it is for the stop's place on the stop's day: a table booked at the Bullerei on
* the day the Bullerei is driven to is the Bullerei's, pinned or not. The night, the
* ride and the hire car are left out because the rail already draws them as the stay,
* the terminals and the desks.
*/
export interface DayBookings {
/** By index into `day.stops`, earliest first. Indexes without a booking are absent. */
atStop: Map<number, Reservation[]>
/** On the day, at no stop of it. */
loose: Reservation[]
}
const EMPTY: DayBookings = { atStop: new Map(), loose: [] }
function drawnElsewhere(type: string): boolean {
return type === 'hotel' || isRentalType(type) || isCarrierType(type)
}
/** Earliest first, untimed last, id as the tiebreaker: the day plan's own order. */
function byClock(a: Reservation, b: Reservation): number {
const at = a.reservation_time || ''
const bt = b.reservation_time || ''
if (at !== bt) {
if (!at) return 1
if (!bt) return -1
return at < bt ? -1 : 1
}
return a.id - b.id
}
/**
* Whether a booking is on the day: it starts or ends there, or the day lies between the
* two, the way the day plan lists a three-day tour on its middle day as well. A type
* that has nothing to say on its middle days (a parked car) is left off them here too.
* Without a day order only the first and the last day of a span can be told.
*/
function spansDay(r: Reservation, dayId: number, dayOrder?: (dayId: number) => number | null | undefined): boolean {
if (r.day_id === dayId || (r.end_day_id != null && r.end_day_id === dayId)) return true
if (!dayOrder || r.day_id == null || r.end_day_id == null) return false
const here = dayOrder(dayId)
const first = dayOrder(r.day_id)
const last = dayOrder(r.end_day_id)
if (here == null || first == null || last == null) return false
return here > Math.min(first, last) && here < Math.max(first, last) && !hidesOnMiddleDay(r, dayId)
}
export function dayBookings(
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
reservations: readonly Reservation[],
/** A day's position in the trip by its id, so a booking spanning days is on the days between too. */
dayOrder?: (dayId: number) => number | null | undefined,
): DayBookings {
if (!reservations.length) return EMPTY
const atStop = new Map<number, Reservation[]>()
const loose: Reservation[] = []
for (const r of reservations) {
if (drawnElsewhere(r.type)) continue
const stops = day.stops
// Only a real stop can hold a booking: a terminal has a negative place and an
// automatic night has no assignment anybody pinned to.
const real = (i: number): boolean => !stops[i]!.carrier && !stops[i]!.automaticNight
let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
const onDay = spansDay(r, day.dayId, dayOrder)
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
if (index >= 0) {
const list = atStop.get(index) ?? []
list.push(r)
atStop.set(index, list)
} else if (onDay) {
loose.push(r)
}
}
for (const list of atStop.values()) list.sort(byClock)
loose.sort(byClock)
return atStop.size || loose.length ? { atStop, loose } : EMPTY
}
/** The booking panel's icon for the type, so a table reads as a table here too. */
export function bookingIcon(type: string): LucideIcon {
return (RES_ICONS as Record<string, LucideIcon>)[type] ?? Ticket
}
/** The clock the booking starts at, or null when it names none. */
export function bookingClock(r: Pick<Reservation, 'reservation_time'>): string | null {
return splitReservationDateTime(r.reservation_time).time || null
}
@@ -261,4 +261,42 @@ describe('useCorridorPois', () => {
expect(result.current.failedAreas).toBeGreaterThan(0)
expect(pois.mock.calls.length).toBeLessThan(result.current.progress.total + 1)
})
it('FE-ROADTRIP-CORRIDOR-018: a ride is left out of the search, and a hit under the flight path is dropped (#2428)', async () => {
// A long line whose middle is a flight: boxes are asked for the two driven ends only,
// and nothing found between the two terminals is on the way.
const from = LONG[3]
const to = LONG[8]
pois.mockResolvedValue(answer(hit('under-the-flight', LONG[5].lat, LONG[5].lng), hit('on-the-road', LONG[1].lat, LONG[1].lng + 0.01)))
const { result } = renderHook(() => useCorridorPois(LONG, ['fuel'], 10, { gaps: [{ from, to }] }))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
const asked = pois.mock.calls.map(c => c[1] as { south: number; north: number })
// No box covers the middle of the ride.
expect(asked.some(b => b.south <= LONG[5].lat && b.north >= LONG[5].lat && b.north - b.south < 0.2)).toBe(false)
expect(result.current.results.map(r => r.osm_id)).toEqual(['on-the-road'])
// The same line without the ride searches straight through.
const { result: plain } = renderHook(() => useCorridorPois(LONG, ['fuel'], 10))
act(() => { plain.current.search() })
await waitFor(() => expect(plain.current.loading).toBe(false))
expect(plain.current.results.map(r => r.osm_id).sort()).toEqual(['on-the-road', 'under-the-flight'])
expect(plain.current.progress.total).toBeGreaterThan(result.current.progress.total)
})
it('FE-ROADTRIP-CORRIDOR-019: the gaps are a dependency like any other, so a memoised array keeps `search` and a fresh one remakes it', () => {
// The caller memoises the array per day; the hook does not key it on its contents
// behind the lint rule's back.
const gaps = [{ from: LONG[3], to: LONG[8] }]
const categories = ['fuel']
const { result, rerender } = renderHook(
({ options }: { options: { gaps: typeof gaps } }) => useCorridorPois(LONG, categories, 10, options),
{ initialProps: { options: { gaps } } },
)
const search = result.current.search
rerender({ options: { gaps } })
expect(result.current.search).toBe(search)
rerender({ options: { gaps: [...gaps] } })
expect(result.current.search).not.toBe(search)
})
})
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, sliceAtMeters, type Bbox, type LatLng } from './corridor'
import { corridorTiles, drivenPieces, inRiddenRange, projectOntoRoute, riddenRanges, simplifyLine, type Bbox, type LatLng } from './corridor'
import { DESKTOP_CORRIDOR_BUDGET, type CorridorBudget } from './corridorSearchModel'
import type { Poi } from '../Map/poiCategories'
@@ -85,9 +85,22 @@ export function useCorridorPois(
* is not a desk and a search that keeps the radio warm for a minute is a search that
* costs battery somebody is navigating on.
*/
options?: { budget?: CorridorBudget },
options?: {
budget?: CorridorBudget
/**
* Stretches of the line the car never drives (#2428): a flight, a ferry, a train
* between two terminals. The line runs straight from one terminal to the other
* there, and boxing that would search open sea, or the countryside under a flight
* path, and offer what it found as being on the way. Each pair is projected onto the
* spine and the stretch between the two is left out of the search, and a hit that
* lands in it is dropped. Memoised by the caller: the array's identity is what
* recreates `search`, so a fresh one every render would recreate it every render.
*/
gaps?: { from: LatLng; to: LatLng }[]
},
): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
const gaps = options?.gaps
const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -137,9 +150,9 @@ export function useCorridorPois(
// Only the TILE line is cut, never the spine: every `alongKm` in a result is a
// distance along the spine, and so is every entry in `stopsAlongKm`. Cutting the
// spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
const allTiles = corridorTiles(tileLine, widthKm)
// wrong position in the day's chain. The MCP corridor tool cuts the same way.
const ridden = riddenRanges(spine, gaps ?? [])
const allTiles = drivenPieces(spine, ridden, window).flatMap(piece => corridorTiles(piece, widthKm))
const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
@@ -184,7 +197,7 @@ export function useCorridorPois(
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm) continue
if (!hit || hit.offRouteKm > widthKm || inRiddenRange(ridden, hit.alongKm)) continue
seen.set(poi.osm_id, {
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
@@ -251,7 +264,7 @@ export function useCorridorPois(
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs])
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gaps])
useEffect(() => () => abortRef.current?.abort(), [])
@@ -313,4 +313,37 @@ describe('useRoadtripCorridor', () => {
expect(result.current.insertIndexFor(beforeFirstStop)).toBe(1)
})
})
it('FE-ROADTRIP-CORRIDORSTATE-038: the leg between a departure terminal and its arrival is no section', () => {
// The search leaves the ride out and drops every hit inside it, so a section around
// its midpoint could only ever come back empty. The two terminals stay offered.
const terminal = (role: 'departure' | 'arrival', index: number, lat: number, lng: number) => ({
assignmentId: -3000000014 - index, ownerDayId: 1, ownerIndex: index, placeId: -7, name: role, lat, lng,
time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
})
const spine = [
{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }, { lat: 52.0, lng: 14.5 },
]
useCorridorPois.mockReturnValue({ ...searchWith([]), spine })
const [first, last] = day().stops
const flightDay = day({
stops: [
first,
terminal('departure', 1, 53.0, 11.0),
terminal('arrival', 2, 52.5, 13.4),
{ ...last, assignmentId: 3, ownerIndex: 3, lat: 52.0, lng: 14.5 },
],
geometry: spine.map(p => [p.lat, p.lng] as [number, number]),
})
const { result } = renderHook(() => useRoadtripCorridor(routes([flightDay])))
// On the old code the list also carries 'leg:1', the midpoint of the flight.
expect(result.current.anchors.map(a => `${a.kind}:${a.index}`))
.toEqual(['stop:0', 'leg:0', 'stop:1', 'stop:2', 'leg:2', 'stop:3'])
// The same ride is what the search is told to leave out.
const asked = useCorridorPois.mock.calls[useCorridorPois.mock.calls.length - 1]
expect(asked[3].gaps).toEqual([{ index: 1, from: { lat: 53.0, lng: 11.0 }, to: { lat: 52.5, lng: 13.4 } }])
})
})
@@ -3,7 +3,7 @@ import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCor
import type { CorridorBudget } from './corridorSearchModel'
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
import type { SectionAnchor } from './roadtripModel'
import { projectOntoRoute, type LatLng } from './corridor'
import { projectOntoRoute, rideGaps, type LatLng } from './corridor'
import { useVehicleRange } from './useVehicleRange'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
@@ -119,7 +119,11 @@ export function useRoadtripCorridor(
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day])
const search = useCorridorPois(line, categories, widthKm, options)
// The rides on this day: between a departure terminal and its arrival the car is not
// on the road, so that stretch of the line is not searched (#2428).
const rides = useMemo(() => (day ? rideGaps(day.stops) : []), [day])
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps: rides }), [options, rides]))
/**
* Whether what the panel looks for has been decided yet.
@@ -193,8 +197,14 @@ export function useRoadtripCorridor(
* Resolved here rather than stored: `section` names an anchor, and its kilometre figure
* is looked up fresh, so changing the corridor width does not move the point the list
* is narrowed around.
*
* No break is planned in the middle of a ride: the search never covers it, so the leg
* between a departure terminal and its arrival is not offered as a section.
*/
const anchors = useMemo(() => sectionAnchors(stopsAlongKm), [stopsAlongKm])
const anchors = useMemo(
() => sectionAnchors(stopsAlongKm).filter(a => a.kind !== 'leg' || !rides.some(r => r.index === a.index)),
[stopsAlongKm, rides],
)
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
@@ -849,3 +849,165 @@ describe('a visit End on the road trip', () => {
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
}))
describe('a booking the traveller rides (#2428)', () => {
const MUC: [number, number] = [48.3538, 11.7861]
const HAM: [number, number] = [53.6304, 9.9882]
const flight = (over: Record<string, unknown> = {}) => ({
id: 70,
trip_id: 7,
title: 'LH 2020 HAM → MUC',
type: 'flight',
status: 'confirmed',
day_id: 1,
end_day_id: 1,
reservation_time: '13:20',
reservation_end_time: '14:30',
endpoints: [
{ role: 'from', sequence: 0, name: 'Hamburg Airport', code: 'HAM', lat: HAM[0], lng: HAM[1], timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'Munich Airport', code: 'MUC', lat: MUC[0], lng: MUC[1], timezone: null, local_time: null, local_date: null },
],
...over,
}) as unknown as import('../../types').Reservation
/** An hour for whatever pair the router is handed. */
const hourly = (points: { lat: number; lng: number }[]) => ({
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
distance: 100000 * (points.length - 1),
duration: 3600 * (points.length - 1),
legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
})
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
it('FE-ROADTRIP-ROUTES-040: the terminals seam the day, the ride is never routed, and the roads to and from them are', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
{ id: 2, at: LUENEBURG, time: '10:00', dwell: 0 },
{ id: 3, at: BERLIN, dwell: 0 },
]
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [flight()]))
await waitFor(() => expect(result.current.loading).toBe(false))
const d = result.current.days[0]
expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'Stop 2', 'departure', 'arrival', 'Stop 3'])
// Two road runs went to the router: up to the departure terminal, and from the arrival on.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
const asked = calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
expect(asked).toEqual([[HAMBURG[0], LUENEBURG[0], HAM[0]], [MUC[0], BERLIN[0]]])
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'flight', 'driving'])
expect(d.legs[2]).toMatchObject({ distance: 0, duration: 70 * 60 })
// An hour ahead of the flight at the airport, off on the timetable, landed on it.
expect(d.schedule.entries.map(e => [e.arrival, e.departure])).toEqual([
['09:00', '09:00'],
['10:00', '10:00'],
['12:20', '13:20'],
['14:30', '14:30'],
['15:30', '15:30'],
])
// The ride draws no road of its own; the three road legs do.
expect(result.current.lines).toHaveLength(3)
expect(result.current.totalStops).toBe(3)
expect(result.current.totalDistance).toBe(300000)
})
it('FE-ROADTRIP-ROUTES-041: a ride landing on a later day seams two cards, and with connected days the ride is the join', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
const days = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
}
const overnight = flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 })
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], [overnight]))
await waitFor(() => expect(result.current.loading).toBe(false))
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.mode).toBe('flight'))
expect(result.current.days[0].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'departure'])
expect(result.current.days[1].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 2'])
expect(result.current.days[1].schedule.entries[0].arrival).toBe('07:00')
// The join between the days is the ride, and the router was asked for the two roads only.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 })
})
it('FE-ROADTRIP-ROUTES-044: an edited timetable changes the ride minutes without a routing round, and the chain is not late for it', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, { id: 2, at: BERLIN, dwell: 0 }]
const { result, rerender } = renderHook(
({ reservations }) => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], reservations),
{ initialProps: { reservations: [flight()] } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].legs[1]).toMatchObject({ mode: 'flight', duration: 70 * 60 })
// The same terminals, the same roads: the landing is now forty minutes after take-off.
rerender({ reservations: [flight({ reservation_end_time: '14:00' })] })
await waitFor(() => expect(result.current.days[0].legs[1]?.duration).toBe(40 * 60))
const d = result.current.days[0]
expect(d.legs[1]?.durationText).toBe('40 min')
expect(d.schedule.entries.map(e => e.arrival)).toEqual(['09:00', '12:20', '14:00', '15:00'])
expect(d.schedule.warnings.filter(w => w.code === 'late')).toEqual([])
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-ROUTES-045: an overnight ride is the join between its cards, and a moved landing moves it too', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
const days = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
}
const overnight = (landing: string) => flight({ reservation_time: '22:00', reservation_end_time: landing, end_day_id: 2 })
const { result, rerender } = renderHook(
({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], reservations),
{ initialProps: { reservations: [overnight('07:00')] } },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[1]?.arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600 })
// The ride is derived, not fetched: the router saw the two roads and nothing else.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
rerender({ reservations: [overnight('06:30')] })
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.duration).toBe(8.5 * 3600))
expect(result.current.days[1].schedule.entries[0].arrival).toBe('06:30')
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or a taxi, changes nothing', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
const unlocated = flight({ endpoints: [] })
const taxi = flight({ id: 71, type: 'taxi' })
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, taxi]))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-ROUTES-043: a hire car puts its desks on the road, one run through both, and no ride', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: LUENEBURG, time: '10:00', dwell: 0 }, { id: 2, at: BERLIN, time: '14:00', dwell: 0 }]
const hireCar = flight({
id: 71,
type: 'car',
title: 'Sixt',
reservation_time: '09:00',
reservation_end_time: '18:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'Sixt Hamburg', code: null, lat: HAMBURG[0], lng: HAMBURG[1], timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'Sixt Berlin', code: null, lat: BERLIN[0] + 0.01, lng: BERLIN[1], timezone: null, local_time: null, local_date: null },
],
})
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [hireCar]))
await waitFor(() => expect(result.current.loading).toBe(false))
const d = result.current.days[0]
expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 1', 'Stop 2', 'return'])
// One road, the whole day, desk to desk.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'driving'])
expect(d.schedule.entries[0]).toMatchObject({ arrival: '09:00', departure: '09:00' })
expect(result.current.totalStops).toBe(2)
expect(result.current.lines).toHaveLength(3)
})
})
@@ -1,5 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
@@ -7,7 +7,7 @@ import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel'
import { spillChains } from './nightSpill'
import { useSettingsStore } from '../../store/settingsStore'
import { useVehicleRange } from './useVehicleRange'
import type { Assignment, AssignmentsMap, Accommodation, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
import type { Assignment, AssignmentsMap, Accommodation, Day, Reservation, RouteAvoidClass, SnappedWaypoint } from '../../types'
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
import { dayWindow } from './dayWindow'
import { useTranslation } from '../../i18n/TranslationContext'
@@ -60,9 +60,7 @@ const stopKey = (s: RoadtripStop): string =>
* so the two cannot drift and quietly stop refetching.
*/
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string =>
(viasByDay[from.ownerDayId] ?? [])
.filter(v => v.after_order_index === from.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
viasLeaving(from, viasByDay[from.ownerDayId] ?? [])
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
.join('|')
@@ -70,6 +68,7 @@ const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>)
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
const EMPTY_RESERVATIONS: Reservation[] = []
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
const p = a.place
@@ -91,6 +90,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda
// traveller's own statement about this stop, and the stay then runs until it.
leaveAt: a.assignment_end_time ?? p.end_time ?? null,
checkInTime: stay?.check_in ?? null,
night: stay !== undefined,
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
endDay: a.end_day === true,
legMode: a.leg_transport_mode ?? null,
@@ -126,6 +126,13 @@ export function useRoadtripRoutes(
viasByDay: Record<number, RoadtripVia[]> = {},
boundaries: RoadtripDayBoundary[] = [],
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS,
/**
* The trip's bookings, for the ones the traveller rides (#2428). A flight, train,
* ferry, cruise or bus with located terminals seams the drive: the road ends at the
* terminal it leaves from and starts again at the one it lands at, and the leg
* between the two is the ride, never a road.
*/
reservations: Reservation[] = EMPTY_RESERVATIONS,
): RoadtripRoutes {
const { t } = useTranslation()
const routeProfile = fallbackProfile || 'driving'
@@ -201,47 +208,70 @@ export function useRoadtripRoutes(
[legMinutes, dayMinutes, planningRangeKm, fillPercent],
)
const plan = useMemo<PlanDay[]>(() => {
// The seams the bookings make. Only the rides matter here, and only their days,
// clocks, terminals and seats, so a renamed booking or a new note on it changes nothing.
const rideSeams = useMemo<CarrierSeam[]>(
() => reservations.map(r => carrierSeam(r)).filter((seam): seam is CarrierSeam => seam !== null),
[reservations],
)
// Every stored day as the road trip reads it: its located stops in stored order, with
// the terminals of its rides seated among them. Built once and split below, because
// the two halves are one list read two ways.
const storedDays = useMemo<PlanDay[]>(() => {
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(d => {
const stops = (assignments[String(d.id)] ?? [])
const located = (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(a => asStop(a, d.id, 0, accommodations))
.filter((s): s is RoadtripStop => s !== null)
// The index is filled in after the drop, because it is the index into THIS
// list: an assignment whose place has no coordinates never becomes a stop, and
// counting before the filter would name a row the rail does not draw.
.map((s, i) => ({ ...s, ownerIndex: i }))
.map(a => ({ assignment: a, stop: asStop(a, d.id, 0, accommodations) }))
.filter((x): x is { assignment: Assignment; stop: RoadtripStop } => x.stop !== null)
// The index is filled in after the drop, because it is the index into THIS
// list: an assignment whose place has no coordinates never becomes a stop, and
// counting before the filter would name a row the rail does not draw.
const stops = located.map((x, i) => ({ ...x.stop, ownerIndex: i }))
return {
dayId: d.id,
dayNumber: d.day_number ?? 0,
date: d.date ?? null,
title: d.title ?? null,
stops,
stops: seatCarrierStops(d.id, stops, located.map((x, i) => x.assignment.order_index ?? i), rideSeams),
}
})
.filter(d => d.stops.length > 1)
}, [days, assignments, accommodations])
}, [days, assignments, accommodations, rideSeams])
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(a => asStop(a, d.id, 0, accommodations))
.filter((s): s is RoadtripStop => s !== null)
.map((s, i) => ({ ...s, ownerIndex: i })),
}))
.filter(d => d.stops.length < 2)
}, [days, assignments, accommodations])
const plan = useMemo<PlanDay[]>(() => storedDays.filter(d => standsAsDay(d.stops)), [storedDays])
const quietDays = useMemo<QuietDay[]>(() => storedDays.filter(d => !standsAsDay(d.stops)), [storedDays])
/** Days apart, for a ride that lands on a later day than it left. */
const dayNumberOf = (dayId: number): number => days.find(d => d.id === dayId)?.day_number ?? 0
/**
* The leg between the two terminals of every ride, keyed like a routed leg.
*
* Derived from the bookings rather than fetched, and kept out of the routing round on
* purpose: that round only runs when the geometry changes, and a ride's minutes come
* from its clocks. Filed inside the round, an edited departure time left the old
* minutes in place until something else re-routed, so the chain reached the arrival
* terminal late for a flight that had been moved earlier.
*/
const rideLegs = useMemo(() => {
const terminals = new Map<number, { stop: RoadtripStop; dayNumber: number }>()
for (const day of storedDays)
for (const stop of day.stops)
if (stop.carrier) terminals.set(stop.assignmentId, { stop, dayNumber: day.dayNumber })
const out: Record<string, RoutedLeg> = {}
for (const seam of rideSeams) {
if (seam.kind !== 'ride') continue
const dep = terminals.get(terminalAssignmentId(seam.reservationId, 'departure'))
const arr = terminals.get(terminalAssignmentId(seam.reservationId, 'arrival'))
if (!dep || !arr) continue
Object.assign(out, carrierLegsFor([dep.stop, arr.stop], seam.type, () => arr.dayNumber - dep.dayNumber, legKey))
}
return out
}, [storedDays, rideSeams])
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round.
@@ -300,6 +330,17 @@ export function useRoadtripRoutes(
* into a burst at the one host that refused it.
*/
const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => {
// A ride is no road, so no router is asked. The two ends of a booked ride have
// their leg in `rideLegs`, which keeps step with an edited timetable; a terminal
// standing beside an ordinary stop is the other carrier-mode pair, and it still
// needs a leg of its own. Without one `computeSchedule` loses its cursor there and
// every arrival behind it goes blank. A run is one stored day, so its ends are
// never days apart.
if (isCarrierMode(mode)) {
const legs = carrierLegsFor(run, mode, () => 0, legKey) ?? {}
for (const [key, leg] of Object.entries(legs)) if (!rideLegs[key]) dayLegs[key] = leg
return
}
/** The same stops, as one request per pair. A pair has nothing left to split. */
const splitIntoPairs = (): void => {
if (run.length <= 2) return
@@ -322,9 +363,7 @@ export function useRoadtripRoutes(
// index — this used to be the position within the chain, so on any day that
// received a night drive every via matched nothing and quietly stopped
// shaping the road.
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
.sort((a, b) => a.sequence - b.sequence)
viasLeaving(stop, viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
})
@@ -404,22 +443,29 @@ export function useRoadtripRoutes(
// 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.
const publish = (): void => {
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
}
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 })
setMissedByDay({ ...collectedMisses })
publish()
// 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)
if (controller.signal.aborted) return
// A plan that is nothing but rides asked for no road, and still replaces what
// the last round left behind.
if (!tasks.length) publish()
setLoading(false)
})()
return () => controller.abort()
@@ -443,9 +489,10 @@ export function useRoadtripRoutes(
// every routing round while `seamLegs` only ever grows, so a pair that used to sit on
// a day boundary and now sits inside one day keeps an answer nobody re-asks for, and
// that older answer was shaped by the vias of a stop that has since moved. Where both
// exist the day run is the newer of the two, so it is the one to believe.
return { ...seamLegs, ...out }
}, [plan, legsByDay, seamLegs])
// exist the day run is the newer of the two, so it is the one to believe. The rides
// last: a ride is never a road, whatever was fetched for its pair.
return { ...seamLegs, ...out, ...rideLegs }
}, [plan, legsByDay, seamLegs, rideLegs])
// Which date each stop is actually reached on. Nothing is written to make it so; see
// `nightSpill.ts`.
@@ -464,6 +511,8 @@ export function useRoadtripRoutes(
const seams = useMemo(() => {
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
// The two ends of one ride: the leg between them is the booking's, never a road.
if (rideLegs[legKey(from, to)]) return
// Routed as part of a day's own run — that request is rebuilt whenever its vias
// change, so there is nothing to catch up here.
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
@@ -497,7 +546,7 @@ export function useRoadtripRoutes(
}
}
return out
}, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays])
}, [chains, plan, quietDays, window, legsByDay, seamLegs, rideLegs, viasByDay, connectDays])
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
/** When the last seam request went out, across every run of the effect below. */
const lastSeamRequestAt = useRef(0)
@@ -508,6 +557,22 @@ export function useRoadtripRoutes(
void (async () => {
for (const seam of seams) {
if (controller.signal.aborted) return
const mode = resolveLegMode(
{ isPlace: true, leg_transport_mode: seam.from.legMode },
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
)
// No router knows a ride. A booked ride's own pair has its leg in `rideLegs` and
// never reaches here; what is left is a terminal seamed to an ordinary stop on
// another card, which is a join of no minutes rather than a road. Filed here,
// above the pacing block, so it spends no request slot.
if (isCarrierMode(mode)) {
const key = legKey(seam.from, seam.to)
const legs = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
const leg = legs?.[key]
if (leg) setSeamLegs(prev => ({ ...prev, [key]: { ...leg, shape: seamShape(seam.from, viasByDay) } }))
continue
}
// Paced before the request, against a clock that outlives this effect.
//
// Waiting AFTER one instead spaced nothing: storing a seam changes
@@ -522,19 +587,12 @@ export function useRoadtripRoutes(
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, controller.signal)
if (controller.signal.aborted) return
lastSeamRequestAt.current = performance.now()
const mode = resolveLegMode(
{ isPlace: true, leg_transport_mode: seam.from.legMode },
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
)
try {
// The via points on this seam, threaded in the same way the day runs thread
// theirs. Without them a seam is the one stretch of the trip a via cannot
// shape: it is asked for on its own, so the points the traveller dropped on it
// never reached the router and dragging one did visibly nothing.
const shaping = (viasByDay[seam.from.ownerDayId] ?? [])
.filter(v => v.after_order_index === seam.from.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
const shaping = viasLeaving(seam.from, viasByDay[seam.from.ownerDayId] ?? [])
const r = await calculateRouteWithLegs(
[
{ lat: seam.from.lat, lng: seam.from.lng },
@@ -644,11 +644,11 @@ describe('IntegrationsTab', () => {
expect(createBtn).toBeDisabled();
});
it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
it('FE-COMP-INTEGRATIONS-032: a refused client registration toasts the reason the server gave', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
HttpResponse.json({ error: 'Redirect URI must use HTTPS, loopback HTTP, or a private custom scheme: http://192.168.1.5/cb' }, { status: 400 })
)
);
enableMcp();
@@ -657,9 +657,24 @@ describe('IntegrationsTab', () => {
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://192.168.1.5/cb');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
expect(await screen.findByText(/Redirect URI must use HTTPS/)).toBeInTheDocument();
expect(screen.queryByText('Failed to register OAuth client')).not.toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-032b: a failure the server says nothing about keeps the generic text', async () => {
const user = userEvent.setup();
server.use(http.post('/api/oauth/clients', () => HttpResponse.error()));
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
expect(await screen.findByText(/Failed to register/i)).toBeInTheDocument();
expect(await screen.findByText('Failed to register OAuth client')).toBeInTheDocument();
});
});
@@ -13,6 +13,7 @@ import ApiKeysSection from './ApiKeysSection'
import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import { useAuthStore } from '../../store/authStore'
import { getApiErrorMessage } from '../../utils/apiError'
interface OAuthPreset {
id: string
@@ -266,8 +267,11 @@ function useIntegrations() {
setOauthNewUris('')
setOauthNewScopes([])
setOauthIsMachine(false)
} catch {
toast.error(t('settings.oauth.toast.createError'))
} catch (err) {
// The server names the rule that refused the client (a scope, the
// ten-client cap, a redirect URI it will not take); swallowing it left
// "Failed to register OAuth client" and nothing to act on.
toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError')))
} finally {
setOauthCreating(false)
}
@@ -1,4 +1,4 @@
// FE-RN-001 to FE-RN-014
// FE-RN-001 to FE-RN-016
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
@@ -209,4 +209,15 @@ describe('ReleaseNoticeModal', () => {
// The places card keeps the pill: that one is about the index.
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
})
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
// Below 1080px the panel scrolls and the note is its second screen, so the
// stylesheet sticks the button to the panel's own top edge. That only works
// while it is a child of .rn-panel; put it back in the note and it scrolls a
// whole release half out of reach with every CSS assertion still green.
renderModal(releaseNotice())
const close = screen.getByRole('button', { name: 'Close' })
expect(close.parentElement?.classList.contains('rn-panel')).toBe(true)
expect(close.closest('.rn-note')).toBeNull()
})
})
@@ -64,6 +64,14 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
aria-describedby={bodyId}
className="rn-panel"
>
{/* A child of the panel, not the note: stacked, the panel scrolls and the
note is its second screen, so the button sticks to the panel's edge. */}
{notice.dismissible && (
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
<X size={17} strokeWidth={2} />
</button>
)}
{/* ── Left: the release ─────────────────────────────────────────── */}
<div className="rn-release">
<div className="rn-release-grain" aria-hidden="true" />
@@ -127,12 +135,6 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
{/* ── Right: the note ───────────────────────────────────────────── */}
<div className="rn-note">
{notice.dismissible && (
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
<X size={17} strokeWidth={2} />
</button>
)}
<div className="rn-note-body">
<div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div>
<h3 className="rn-note-title">{t(release.note.titleKey)}</h3>
@@ -50,6 +50,7 @@
.rn-close { box-sizing: content-box; }
.rn-panel {
position: relative;
width: min(1420px, calc(100% - 2px));
max-height: 100%;
display: grid;
@@ -67,9 +68,68 @@
line-height: normal;
}
/* Below the two-column breakpoint the halves stack and the panel scrolls. */
/* The panel's, not the note's: in the stacked layout below it has to outlast the
release half, which scrolls by first. */
.rn-close {
position: absolute;
top: 22px;
/* Logical, so the X sits at the reading end in Arabic too. With the physical
right it stayed over the release half there, which the note's palette below
is not made for, and the stacked layout put it at the other end anyway. */
inset-inline-end: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
z-index: 2;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
/* Below the two-column breakpoint the halves stack and the panel scrolls. The
rows must go back to auto: with the single minmax row from above, the note took
the whole panel and the release half was left with nothing but its padding. */
@media (max-width: 1080px) {
.rn-panel { grid-template-columns: 1fr; overflow-y: auto; }
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto auto; overflow-y: auto; }
.rn-release { grid-area: 1 / 1; }
.rn-note { grid-area: 2 / 1; }
.rn-release, .rn-release-inner, .rn-note-body { overflow: visible; }
/* The X used to sit in the note, a whole release half below the top edge, and
at a tablet's width the backdrop is gone too. As a grid item over both rows
it sticks 22px under the top edge for the entire scroll, over either half. */
.rn-close {
position: sticky;
grid-row: 1 / -1;
grid-column: 1;
justify-self: end;
align-self: start;
margin: 22px;
/* At the top of the scroll it hangs over the release half, which is dark in
both themes. The note's palette is a near-transparent fill and secondary
ink: in the light theme that is a slate glyph on near black, roughly 1.8:1,
so the only way to dismiss the notice on a tablet was invisible. An opaque
chip with primary ink carries over either half, the way .rn-support below
gets a solid band once it sticks. */
background: color-mix(in srgb, var(--bg-card) 92%, var(--text-primary));
border-color: var(--border-primary);
color: var(--text-primary);
box-shadow: 0 2px 8px oklch(0 0 0 / 0.25);
}
/* Once the note has scrolled into view, the ask stays at the bottom edge. The
band's usual tint is translucent, so it gets a solid one here. */
.rn-support {
position: sticky;
bottom: 0;
z-index: 1;
background: color-mix(in srgb, var(--bg-card) 95%, var(--text-primary));
}
}
[data-reduce-motion='true'] .rn-overlay,
@@ -549,26 +609,6 @@
min-height: 0;
}
.rn-close {
position: absolute;
top: 22px;
right: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
z-index: 2;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
.rn-note-body {
flex: 1;
min-height: 0;
@@ -581,6 +621,15 @@
.rn-note-body::-webkit-scrollbar { display: none; }
/*
* The two columns scroll; what is inside them must not give way instead. A flex
* item may shrink below its content, and one with overflow hidden may shrink to
* nothing, so on a 1080p screen at 100% the promise box was pressed down to its
* label while the paragraphs around it kept their height and the column scrolled.
*/
.rn-release-inner > *,
.rn-note-body > * { flex-shrink: 0; }
.rn-note-eyebrow {
font-family: var(--font-subtext);
font-size: 11px;
@@ -12,10 +12,108 @@ describe('release notice css', () => {
const notice = read('src/components/SystemNotices/releaseNotice.css')
const tokens = read('src/index.css')
const block = (selector: string): string => {
const at = notice.indexOf(selector)
const within = (source: string, selector: string): string => {
const at = source.indexOf(selector)
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1)
return notice.slice(at, notice.indexOf('}', at) + 1)
return source.slice(at, source.indexOf('}', at) + 1)
}
const block = (selector: string): string => within(notice, selector)
// A half is named twice: indented in the stacked block, where it is only placed
// in the grid, and at the top level, where it is painted. Anchor on the newline
// to get the second.
const painted = (selector: string): string => within(notice, `\n${selector}`)
const stacked = (): string => {
const at = notice.indexOf('@media (max-width: 1080px)')
expect(at, 'the stacked block').toBeGreaterThan(-1)
return notice.slice(at, notice.indexOf('\n}', at))
}
// How many rules deep a position sits, comments taken out so a brace in prose
// cannot shift the count. Zero means the rule applies at every width.
const depthAt = (index: number): number => {
let depth = 0
for (const ch of notice.slice(0, index).replace(/\/\*[\s\S]*?\*\//g, '')) {
if (ch === '{') depth += 1
else if (ch === '}') depth -= 1
}
return depth
}
const decl = (rule: string, prop: string): string => {
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*([^;]+);`).exec(rule)
expect(found, `${prop} missing from ${rule.slice(0, 44)}`).not.toBeNull()
return found![1].trim()
}
interface Rgba { r: number; g: number; b: number; a: number }
// The custom properties of one theme, so a declaration can be resolved the way
// the browser resolves it instead of being taken on trust.
const theme = (opener: string): Record<string, string> => {
const at = tokens.indexOf(opener)
expect(at, `${opener} missing from index.css`).toBeGreaterThan(-1)
const body = tokens.slice(at, tokens.indexOf('\n}', at))
const map: Record<string, string> = {}
for (const [, token, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) map[token] = value.trim()
return map
}
const rgba = (value: string, vars: Record<string, string>): Rgba => {
const v = value.trim()
const ref = /^var\((--[\w-]+)\)$/.exec(v)
if (ref) {
const token = vars[ref[1]]
expect(token, `${ref[1]} missing from index.css`).toBeDefined()
return rgba(token, vars)
}
const mix = /^color-mix\(in srgb,\s*(.+?)\s+([\d.]+)%,\s*(.+)\)$/.exec(v)
if (mix) {
const share = Number(mix[2]) / 100
const first = rgba(mix[1], vars)
const second = rgba(mix[3], vars)
const blend = (x: number, y: number): number => x * share + y * (1 - share)
return {
r: blend(first.r, second.r),
g: blend(first.g, second.g),
b: blend(first.b, second.b),
a: blend(first.a, second.a)
}
}
const hex = /^#([0-9a-f]{6})$/i.exec(v)
if (hex) {
return {
r: parseInt(hex[1].slice(0, 2), 16),
g: parseInt(hex[1].slice(2, 4), 16),
b: parseInt(hex[1].slice(4, 6), 16),
a: 1
}
}
const fn = /^rgba?\(([^)]+)\)$/.exec(v)
expect(fn, `${value} is not a colour this test can resolve`).not.toBeNull()
const parts = fn![1].split(',').map(Number)
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
}
const over = (front: Rgba, back: Rgba): Rgba => ({
r: front.r * front.a + back.r * (1 - front.a),
g: front.g * front.a + back.g * (1 - front.a),
b: front.b * front.a + back.b * (1 - front.a),
a: 1
})
const contrast = (one: Rgba, other: Rgba): number => {
const luminance = (c: Rgba): number => {
const channel = (raw: number): number => {
const v = raw / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b)
}
const [light, dark] = [luminance(one), luminance(other)].sort((a, b) => b - a)
return (light + 0.05) / (dark + 0.05)
}
const step = (name: string): number => {
@@ -36,4 +134,71 @@ describe('release notice css', () => {
expect(step('--z-notice')).toBeGreaterThan(step('--z-nav'))
expect(step('--z-notice')).toBeGreaterThan(61)
})
it('FE-COMP-RELEASENOTICECSS-003: what the columns hold keeps its height, the columns scroll', () => {
// A flex item with overflow hidden may shrink to nothing. The promise box is
// one, and on a 1080p screen at 100% it was pressed down to its label while
// the column around it scrolled. jsdom lays nothing out, so the rule itself
// is the assertion.
const columns = block('.rn-release-inner > *,')
expect(columns).toMatch(/\.rn-note-body > \*/)
expect(columns).toMatch(/flex-shrink:\s*0/)
expect(block('.rn-promise {')).toMatch(/overflow:\s*hidden/)
})
it('FE-COMP-RELEASENOTICECSS-004: stacked, the close button stays on screen for the whole scroll', () => {
// Below 1080px the panel scrolls as one column with the release half first.
// The X lived in the note, so on a tablet it was a whole release half below
// the fold, and at 900px and under there is no backdrop to tap instead. It
// is a grid item spanning both rows now, stuck to the panel's top edge; the
// base rule is absolute and must come first or it wins the cascade.
const at = notice.indexOf('@media (max-width: 1080px)')
const base = notice.indexOf('.rn-close {\n position: absolute')
expect(base, 'the absolute close rule').toBeGreaterThan(-1)
expect(at).toBeGreaterThan(base)
const close = within(stacked(), '.rn-close {')
expect(close).toMatch(/position:\s*sticky/)
expect(close).toMatch(/grid-row:\s*1 \/ -1/)
// Both halves need a definite cell, or auto-placement steps around the button.
expect(stacked()).toMatch(/\.rn-release \{ grid-area: 1 \/ 1; \}/)
expect(stacked()).toMatch(/\.rn-note \{ grid-area: 2 \/ 1; \}/)
expect(block('.rn-panel {')).toMatch(/position:\s*relative/)
// Sticky without an offset never sticks, and the offset is the base rule's:
// scope that into a width query of its own and the button silently scrolls
// away again with every assertion above still green.
expect(decl(notice.slice(base), 'top'), 'the close button has no offset to stick to').toMatch(/^\d+px$/)
expect(depthAt(base), 'the offset the sticky rule leans on sits inside a query').toBe(0)
})
it('FE-COMP-RELEASENOTICECSS-005: stacked, the close button reads over the release half too', () => {
// Stuck to the panel's top edge the X hangs over .rn-release, a brand panel
// that is dark in both themes, but it kept the palette it had on the white
// note: a near-transparent fill under secondary ink. Resolved against the real
// tokens that is a slate glyph on near black in the light theme, about 1.8:1,
// and below 1080px this button is the only way out of the notice. Both halves
// are one scroll, so the chip has to carry over either.
const gradient = decl(painted('.rn-release {'), 'background')
const backdrops = [...gradient.matchAll(/#[0-9a-f]{6}/gi)].map(match => match[0])
expect(backdrops.length, 'the release half paints no gradient stops').toBeGreaterThan(1)
backdrops.push(decl(painted('.rn-note {'), 'background'))
const themes = [
{ name: 'light', vars: theme(':root {') },
{ name: 'dark', vars: theme('.dark {') }
]
const states = [
{ name: 'at rest', rule: within(stacked(), '.rn-close {') },
{ name: 'hovered', rule: block('.rn-close:hover {') }
]
for (const { name, vars } of themes) {
for (const state of states) {
const glyph = rgba(decl(state.rule, 'color'), vars)
for (const behind of backdrops) {
const chip = over(rgba(decl(state.rule, 'background'), vars), rgba(behind, vars))
expect(contrast(glyph, chip), `${name}, ${state.name}, over ${behind}`).toBeGreaterThanOrEqual(4.5)
}
}
}
})
})
+135
View File
@@ -1375,6 +1375,141 @@ img[alt="TREK"] {
justify-content: center;
}
/* Shared links in the Collab tab: one chip per link, two to a row across the full
width, one to a row where the panel is narrow. The chip is the link; edit, pin and
delete sit quietly in its tail and come forward on hover or keyboard focus. They
keep their width throughout: unfolding them reflowed the row under the pointer,
and a chip that grows as you reach for it is a chip you cannot catch. */
.collab-link-grid {
container-type: inline-size;
}
.collab-link-grid__cells {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
@container (max-width: 420px) {
.collab-link-grid__cells { grid-template-columns: minmax(0, 1fr); }
}
.collab-link-chip {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 5px 8px 5px 5px;
border-radius: 999px;
background: var(--bg-secondary);
border: 1px solid var(--border-faint);
box-shadow: var(--shadow-sm);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}
.collab-link-chip:hover, .collab-link-chip:focus-within {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
border-color: var(--border-primary);
}
.collab-link-chip--pinned {
background: var(--accent-subtle);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}
.collab-link-chip__main {
display: flex;
align-items: center;
gap: 7px;
flex: 1 1 auto;
min-width: 0;
color: var(--text-primary);
text-decoration: none;
outline: none;
}
.collab-link-chip__icon {
position: relative;
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: 0 0 24px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-card);
color: var(--text-faint);
}
.collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; }
.collab-link-chip__icon > * { transition: opacity 0.15s ease; }
/* Reaching for the chip turns its favicon into the open-in-new-tab glyph: the
affordance without a glyph of its own taking room from the title. */
.collab-link-chip__go {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text-primary);
opacity: 0;
}
.collab-link-chip__main:hover .collab-link-chip__go, .collab-link-chip__main:focus-visible .collab-link-chip__go { opacity: 1; }
.collab-link-chip__main:hover .collab-link-chip__icon > :not(.collab-link-chip__go),
.collab-link-chip__main:focus-visible .collab-link-chip__icon > :not(.collab-link-chip__go) { opacity: 0; }
/* Title first, host with what is left: a max-content track takes its full width
before the fr track sees any, and shrinks only once the title alone no longer
fits. Two flex items shrank together and both ended in an ellipsis. */
.collab-link-chip__text {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
align-items: baseline;
column-gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.collab-link-chip__title {
font-size: calc(12.5px * var(--fs-scale-body, 1));
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.collab-link-chip__host {
font-size: calc(10.5px * var(--fs-scale-caption, 1));
color: var(--text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.collab-link-chip__actions {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 1px;
margin-left: auto;
opacity: 0.45;
transition: opacity 0.15s ease;
}
.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { opacity: 1; }
.collab-link-chip__action {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-faint);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); }
.collab-link-chip__action--on { color: var(--accent); }
/* Fingers, not a pointer: the phone Collab tab mounts this same chip. 22px targets
a pixel apart put delete one slip away from pin, so the hit box grows to the
30px at a 34px pitch the phone Notes tab uses. The vertical margin gives the
height back, the chip stays 36px. */
@media (hover: none) {
.collab-link-chip__actions { opacity: 1; gap: 4px; }
.collab-link-chip__action { width: 30px; height: 30px; margin: -4px 0; }
}
/* Markdown in Collab Notes */
.collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; }
.collab-note-md em, .collab-note-md-full em { font-style: italic !important; }
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react'
import { adminApi, authApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { placesGoogleOnlyHint } from '../../../utils/placeSource'
import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import MToggle from '../../components/MToggle'
@@ -34,6 +35,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
placeShadowEnabled, setPlaceShadowEnabledState,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
@@ -350,6 +352,17 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
}
/>
<MAdminRow
title={t('admin.placesGoogleOnly.title')}
hint={t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}
trailing={
<MToggle
checked={placesGoogleOnly}
ariaLabel={t('admin.placesGoogleOnly.title')}
onChange={() => handleTogglePlacesGoogleOnly()}
/>
}
/>
</MBlockDisclosure>
</MProviderBlock>
@@ -7,7 +7,7 @@ import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
@@ -388,7 +388,7 @@ export default function MJourneyDetail() {
onClick={() => openLightbox(gallery, i)}
className="relative aspect-square overflow-hidden rounded-[14px]"
>
{photo.media_type === 'video' && !photo.thumbnail_path ? (
{posterlessVideo(photo) ? (
<span className="block h-full w-full bg-[color:var(--m-ic)]" />
) : (
<img src={photoUrl(photo, 'thumbnail')} alt={photo.caption || ''} loading="lazy" className="h-full w-full object-cover" />
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { localIsoDate } from '../../../utils/localDate'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff } from 'lucide-react'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
@@ -16,7 +16,7 @@ import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import { photoUrl, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import JournalBody from '../../../components/Journey/JournalBody'
import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
@@ -33,6 +33,18 @@ interface LocationResult {
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A clip with no poster to show: the tinted tile and play mark the gallery grid
// gives it, since an <img> asked for its thumbnail draws the broken glyph (#2341).
function ClipTile() {
return (
<span className="absolute inset-0 flex items-center justify-center bg-[color:var(--m-ic)]">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
<Play size={12} className="ml-[1px]" fill="currentColor" />
</span>
</span>
)
}
interface MJourneyEntrySheetProps {
entry: JourneyEntry
galleryPhotos: GalleryPhoto[]
@@ -592,7 +604,11 @@ export default function MJourneyEntrySheet({
}
}}
>
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
{posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
)}
</button>
))}
{availableGalleryPhotos.length === 0 && (
@@ -610,7 +626,11 @@ export default function MJourneyEntrySheet({
<div className="mt-[10px] flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className="relative h-16 w-16 overflow-hidden rounded-[13px]">
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
{posterlessVideo(p) ? (
<ClipTile />
) : (
<img src={photoUrl(p)} alt="" className="h-full w-full object-cover" />
)}
{!readOnly && idx > 0 && photos.length > 1 && (
<button
type="button"
@@ -11,6 +11,7 @@ import MChip from '../../components/MChip'
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet'
import MSegmented from '../../components/MSegmented'
import { getApiErrorMessage } from '../../../utils/apiError'
interface OAuthPreset {
id: string
@@ -188,8 +189,11 @@ export default function MSettingsMcp() {
setNewUris('')
setNewScopes([])
setIsMachine(false)
} catch {
toast.error(t('settings.oauth.toast.createError'))
} catch (err) {
// The server names the rule that refused the client (a scope, the
// ten-client cap, a redirect URI it will not take); swallowing it left
// "Failed to register OAuth client" and nothing to act on.
toast.error(getApiErrorMessage(err, t('settings.oauth.toast.createError')))
} finally {
setCreating(false)
}
@@ -381,7 +381,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
dayOrderMap={planner.dayOrderMap}
reservations={planner.reservations}
showReservationStats={true}
visibleConnectionIds={planner.visibleConnections}
// On the stage the rides the chain seams in draw as their booking's own arc,
// toggled on or not, the way the desktop map does it in road trip mode. The
// ride's leg carries no geometry, so without this the road ended at the
// departure terminal and picked up again at the arrival with nothing between.
visibleConnectionIds={onStage ? planner.roadtripConnections : planner.visibleConnections}
// Transport overlay tap → the mobile transport detail sheet (desktop
// routes this through mapTransportDetail into the day sidebar instead).
onReservationClick={(rid: number) => shell.openSheet('transport', { reservationId: rid })}
@@ -43,6 +43,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
const { t, trip, can } = planner
const canEdit = can('day_edit', trip)
const editing = shell.mode === 'edit' && canEdit
const canEditPlaces = can('place_edit', trip)
// Per-segment travel mode (#1281): tap a connector → pick the leg's mode.
const legMenu = useContextMenu()
const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car)
@@ -202,7 +203,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
reorder={reorderFor(row.item)}
drag={dragFor(row)}
onOpen={() => openPlace(row.assignment)}
onEdit={() => tl.editAssignment(row.assignment)}
onEdit={canEditPlaces ? () => tl.editAssignment(row.assignment) : undefined}
onRemove={() => tl.removeAssignment(row.assignment)}
/>
{dayScheduleFor('assignment', row.assignment.id)}
@@ -124,7 +124,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
reorder: ReactNode
drag?: RowDrag
onOpen: () => void
onEdit: () => void
onEdit?: () => void
onRemove: () => void
}) {
const { t } = chrome
@@ -210,9 +210,11 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
</div>
{chrome.editing && (
<span className="flex flex-none items-center gap-1.5">
<ActionCircle label={t('common.edit')} onClick={onEdit}>
<Pencil size={14} strokeWidth={2} />
</ActionCircle>
{onEdit && (
<ActionCircle label={t('common.edit')} onClick={onEdit}>
<Pencil size={14} strokeWidth={2} />
</ActionCircle>
)}
<ActionCircle label={t('planner.removeFromDay')} onClick={onRemove}>
<X size={14} strokeWidth={2} />
</ActionCircle>
@@ -3,14 +3,16 @@ import type { CSSProperties, ReactNode } from 'react'
import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units'
import { formatClockTime } from '../../../../utils/formatters'
import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../../../types'
import type { Reservation, TranslationFn } from '../../../../types'
/**
* The four row types of the mobile drive chain, plus the two bands that interrupt it.
@@ -52,6 +54,16 @@ export interface RowChrome {
*/
function Disc({ row, t, onPickKind }: { row: StopRow; t: TranslationFn; onPickKind?: () => void }) {
const kind = row.stop.stopType ? STOP_KIND_BY_KEY[row.stop.stopType] : undefined
// A terminal wears the booking's icon and is not a control: nobody turns an airport
// into a petrol station.
if (row.stop.carrier) {
const Icon = carrierIcon(row.stop.carrier.type)
return (
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
</span>
)
}
const face = row.service && kind
? (
<span
@@ -134,6 +146,8 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
)
}
if (row.warning) marks.push(<span key="warn">{warningMark(row.warning, chrome)}</span>)
const terminal = row.stop.carrier ? terminalLine(row.stop.carrier, t, chrome.is12h) : null
if (terminal) marks.push(<span key="terminal" className="font-geist text-[0.65625rem] font-medium text-m-muted">{terminal}</span>)
if (!marks.length && row.offRoadMeters) {
marks.push(
<Mark key="off" icon={<Footprints size={10} strokeWidth={2} />}>
@@ -173,6 +187,108 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
cycling: Bike,
}
/** One end of a ride inside its block: name, code, the timetable line, the clock. */
function RideEnd({ row, chrome }: { row: StopRow; chrome: RowChrome }) {
const carrier = row.stop.carrier!
const line = terminalLine(carrier, chrome.t, chrome.is12h)
return (
<span className="flex items-center gap-[8px]">
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-[6px]">
<span className="line-clamp-2 text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{row.stop.name}</span>
{carrier.code && <span className="flex-none font-geist text-[0.65625rem] font-medium text-m-faint">{carrier.code}</span>}
</span>
{line && <span className="mt-[2px] block font-geist text-[0.65625rem] font-medium text-m-muted">{line}</span>}
</span>
{row.time && (
<span dir="ltr" className={`whitespace-nowrap text-[0.8125rem] tabular-nums ${row.pinned ? 'font-semibold text-m-ink' : 'font-medium text-m-faint'}`}>
{formatClockTime(row.time, chrome.is12h)}
</span>
)}
</span>
)
}
/**
* A ride that leaves and lands on the day, as one block in the chain (#2428): the
* booking and its minutes at the top, the terminal it leaves from, the terminal it lands
* at. One block on one disc, because it is one thing, and three rows read as three
* places the day went to. The block is the tap target and opens the booking, the same
* sheet the map's endpoint badge opens.
*/
export function RtRideRow({ row, chrome, onOpen }: {
row: Extract<RoadtripRow, { kind: 'ride' }>
chrome: RowChrome
onOpen?: () => void
}) {
const Icon = carrierIcon(row.carrier.type)
const body = (
<>
<span className="flex items-center gap-[6px] text-m-muted">
<Icon size={12} strokeWidth={2} className="flex-none" aria-hidden="true" />
<span className="truncate font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.06em]">{rideText(row.carrier, row.seg)}</span>
</span>
<RideEnd row={row.departure} chrome={chrome} />
<RideEnd row={row.arrival} chrome={chrome} />
</>
)
const cls = 'my-1 flex min-w-0 flex-col gap-[7px] rounded-[16px] bg-[color:var(--m-ic)] px-[12px] py-[9px] text-start'
return (
<div className="grid items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr' }}>
<span className="flex justify-center">
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
</span>
</span>
{onOpen ? (
<button type="button" onClick={onOpen} className={cls}>{body}</button>
) : (
<span className={cls}>{body}</span>
)}
</div>
)
}
/**
* The bookings a stop carries, under its row (#2428): the table, the tickets, the tour,
* each a chip with the booking panel's icon, its name and the clock it starts at. Chips
* because a booking is a fact about the stop and not a stop of its own. A chip that
* opens is a button; one this reader may not open stays a plain chip, the rule the rail
* follows too (`bookingOpens`).
*/
export function RtBookingChips({ bookings, chrome, canEdit, onOpen }: {
bookings: Reservation[]
chrome: RowChrome
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
canEdit: boolean
onOpen?: (reservation: Reservation) => void
}) {
return (
<div className="grid gap-x-[10px] pb-2" style={{ gridTemplateColumns: '34px 1fr' }}>
<span aria-hidden="true" />
<span className="flex flex-wrap gap-[6px]">
{bookings.map(r => {
const Icon = bookingIcon(r.type)
const clock = bookingClock(r)
const body = (
<>
<Icon size={11} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
<span className="min-w-0 truncate text-[0.75rem] font-medium text-m-ink">{r.title}</span>
{clock && <span className="flex-none font-geist text-[0.65625rem] tabular-nums text-m-faint">{formatClockTime(clock, chrome.is12h)}</span>}
</>
)
const cls = 'inline-flex h-[28px] max-w-full items-center gap-[5px] rounded-full border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[10px]'
return onOpen && bookingOpens(r, canEdit) ? (
<button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button>
) : (
<span key={r.id} className={cls}>{body}</span>
)
})}
</span>
</div>
)
}
/**
* What a leg pill says: how far and how long, or that it is not routed yet.
*
@@ -1,4 +1,4 @@
import { useRef } from 'react'
import { Fragment, useCallback, useMemo, useRef } from 'react'
import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
import { useMRtAlternatives } from './useMRtAlternatives'
import MRtCorridorBar from './MRtCorridorBar'
import MRtAlternativesBar from './MRtAlternativesBar'
import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
import { RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
import MBadge from '../../../components/MBadge'
import MDancingTrek from '../../../components/MDancingTrek'
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
@@ -17,6 +17,9 @@ import { formatClockTime } from '../../../../utils/formatters'
import { isRtlLanguage } from '../../../../i18n'
import type { MTripTabPanelProps } from '../MTripShell'
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
import { getDayOrder } from '../../../../utils/dayOrder'
import type { Reservation } from '../../../../types'
/**
* The road trip tab: one day of the drive, as a chain or on the map.
@@ -77,7 +80,37 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
const canEditPlaces = planner.can('place_edit', planner.trip)
const openStop = (row: StopRow) => {
shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
// A terminal is the booking's, and opens it: there is no stop sheet for an airport.
if (row.stop.carrier) shell.openSheet('transport', { reservationId: row.stop.carrier.reservationId })
else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
}
// Where each day stands in the trip, so a booking spanning several days is listed on
// the days between its ends as well.
const dayOrder = useCallback((dayId: number) => {
const stored = planner.days.find(d => d.id === dayId)
return stored ? getDayOrder(stored, planner.days) : null
}, [planner.days])
// The bookings under the stage's stops and the ones the stage has for no stop, keyed
// by the stop's assignment because the rows carry no index. Opened the way the place
// sheet opens its linked bookings: a transport in its sheet, anything else in its
// editor.
const bookings = useMemo(() => {
const byIndex = stage ? dayBookings(stage, planner.reservations, dayOrder) : null
const atStop = new Map<number, Reservation[]>()
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
return { atStop, loose: byIndex?.loose ?? [] }
}, [stage, planner.reservations, dayOrder])
// A chip that may not be opened is not a button in the first place (`bookingOpens`),
// so nothing is turned away silently here.
const canEditBookings = planner.can('reservation_edit', planner.trip)
const openBooking = (res: Reservation) => {
if (planner.TRANSPORT_TYPES.has(res.type)) {
shell.openSheet('transport', { reservationId: res.id })
return
}
planner.setEditingReservation(res)
planner.setShowReservationModal(true)
}
// The search bar sits in the same band on both halves, at the same offset, so the
@@ -224,20 +257,23 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
<section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1">
{rt.rows.map((row, i) => {
if (row.kind === 'stop') {
const chips = bookings.atStop.get(row.stop.assignmentId)
return (
<RtStopRow
key={`s${i}`}
row={row}
chrome={chrome}
onOpen={() => openStop(row)}
onPickKind={canEditPlaces
? () => shell.openSheet('rtkind', {
placeId: row.stop.placeId,
stopType: row.stop.stopType ?? null,
name: row.stop.name,
})
: undefined}
/>
<Fragment key={`s${i}`}>
<RtStopRow
row={row}
chrome={chrome}
onOpen={() => openStop(row)}
onPickKind={canEditPlaces && !row.stop.carrier
? () => shell.openSheet('rtkind', {
placeId: row.stop.placeId,
stopType: row.stop.stopType ?? null,
name: row.stop.name,
})
: undefined}
/>
{chips && <RtBookingChips bookings={chips} chrome={chrome} canEdit={canEditBookings} onOpen={openBooking} />}
</Fragment>
)
}
if (row.kind === 'leg') {
@@ -256,6 +292,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
/>
)
}
if (row.kind === 'ride') {
return <RtRideRow key={`r${i}`} row={row} chrome={chrome} onOpen={() => shell.openSheet('transport', { reservationId: row.carrier.reservationId })} />
}
if (row.kind === 'auto') return <RtAutoRow key={`a${i}`} phase={row.phase} time={row.time} chrome={chrome} />
if (row.kind === 'spill') {
return <RtSpillRow key={`p${i}`} fromDayNumber={row.fromDayNumber} departs={row.departs} chrome={chrome} />
@@ -279,6 +318,15 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
/>
)
})}
{bookings.loose.length > 0 && (
<div className="mt-1">
<div className="grid gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr' }}>
<span aria-hidden="true" />
<span className="mb-[6px] block font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.08em] text-m-faint">{t('roadtrip.bookings.loose')}</span>
</div>
<RtBookingChips bookings={bookings.loose} chrome={chrome} canEdit={canEditBookings} onOpen={openBooking} />
</div>
)}
</section>
</>
)}
@@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet'
import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal'
import { useTripStore } from '../../../../store/tripStore'
import { useSettingsStore } from '../../../../store/settingsStore'
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
import { useTranslation } from '../../../../i18n'
import { INNER_CLS, TileHeader } from './MTripSheetUi'
import type { MTripSheetsProps } from '../MTripShell'
@@ -20,6 +21,9 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
// The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
// Fed the way the desktop dialog feeds it: the store's assignments and the switch,
// and the export applies the day plan's filter itself, so both shells print the same.
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, planner.tripId)
const open = shell.sheet?.id === 'export'
const dayNotes = useTripStore(s => s.dayNotes)
const [subscribeOpen, setSubscribeOpen] = useState(false)
@@ -44,7 +48,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
trip: planner.trip,
days: planner.days,
places: planner.places,
assignments: planner.assignments,
assignments: planner.storedAssignments,
categories: planner.categories,
dayNotes: flatNotes,
reservations: planner.reservations,
@@ -52,6 +56,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
locale,
timeFormat,
distanceUnit,
showServiceStops,
})
} catch (e) {
planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, Search } from 'lucide-react'
import { Loader2, RotateCcw, Search } from 'lucide-react'
import { mapsApi } from '../../../../api/client'
import { sourceLabelFor } from '../../../../utils/placeSource'
import { useAuthStore } from '../../../../store/authStore'
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../../../utils/placeSource'
import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession'
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
@@ -95,6 +96,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
// The name the whole list carries, for rows that do not name their own index.
const [acSource, setAcSource] = useState('')
// What answered the last full search, for the line that offers Google instead.
const [searchSource, setSearchSource] = useState('')
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const setResolving = useCallback(
(v: boolean) => {
@@ -168,8 +172,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
setQuery('')
}
const handleSearch = async () => {
const trimmed = query.trim()
const handleSearch = async (provider?: 'google') => {
// The retry sends the query the list came from, as the desktop form does:
// the list stays on screen while the field is edited or cleared.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim()
if (!trimmed) return
setSuggestions([])
@@ -183,7 +189,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
setResolving(true)
try {
if (isMapUrl(trimmed)) {
if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
onPick({
@@ -200,9 +206,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
}
// 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))
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias), provider)
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
@@ -284,7 +291,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
/>
<button
type="button"
onClick={handleSearch}
onClick={() => handleSearch()}
disabled={searching}
aria-label={t('common.search')}
className="flex h-10 w-10 flex-none items-center justify-center rounded-[12px] bg-m-act text-m-actfg disabled:opacity-60"
@@ -332,6 +339,20 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
))}
</div>
)}
{/* The same quiet line the desktop form has: the index answers first and
Google only when it finds nothing, so this is how a list with the wrong
place on it reaches Google, where Google holds the key slot. */}
{results.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
<button
type="button"
onClick={() => handleSearch('google')}
disabled={searching}
className="mt-2 inline-flex items-center gap-1 font-geist text-[0.6875rem] text-m-muted disabled:opacity-60"
>
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
{t('places.searchGoogleInstead')}
</button>
)}
</div>
)
}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -261,6 +261,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
</button>
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
open={confirmDelete}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import {
TRANSPORT_TYPE_COLOR,
@@ -279,6 +279,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
</button>
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
open={confirmDelete}
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
import type { ReservationTraveler } from '@trek/shared'
import { avatarSrc } from '../../../../utils/avatarSrc'
import GuestBadge from '../../../../components/shared/GuestBadge'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
import { usePluginStore } from '../../../../store/pluginStore'
/**
* Scroll body shared by the list-style trip tabs (transports, bookings, costs,
@@ -120,6 +122,36 @@ export function StatusDot({ color }: { color: string }) {
return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />
}
/**
* Plugins that declared a reservation-detail slot, mounted at the foot of a
* transport / booking card and scoped to that reservation: the phone half of
* what ReservationsPanel does on the desktop, which the phone cards never had,
* so a widget like Flight Tracker only appeared once the viewport crossed the
* desktop breakpoint (#2440). Sits outside the card's body button (buttons
* cannot nest, and a tap in the frame must not open the detail sheet), and the
* frame follows the host colour scheme like the desktop detail slots do, so a
* kit plugin that goes dark with TREK keeps compositing transparently.
*/
export function ReservationPluginSlots({ tripId, reservationId }: { tripId: number; reservationId: number }) {
const plugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'reservation-detail')
if (plugins.length === 0) return null
return (
<div className="flex flex-col gap-2 px-3 pb-3">
{plugins.map(p => (
<div key={p.id} className="overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card">
<PluginFrame
pluginId={p.id}
tripId={String(tripId)}
reservationId={String(reservationId)}
title={p.name}
surface="detail-slot"
/>
</div>
))}
</div>
)
}
/**
* Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
* The mobile transport / booking cards only display them — assignment editing
+21 -1
View File
@@ -25,7 +25,13 @@ vi.mock('../components/Collections/CollectionFilterBar', () => ({
? <button type="button" onClick={props.onAddPlace}>collections.addPlace</button>
: <div />,
}))
vi.mock('../components/Collections/CollectionMapPanel', () => ({ default: () => <div data-testid="map" /> }))
const mapPanelProps = vi.hoisted(() => ({ current: null as null | { onSelect: (id: number) => void; onDeselect: () => void } }))
vi.mock('../components/Collections/CollectionMapPanel', () => ({
default: (props: { onSelect: (id: number) => void; onDeselect: () => void }) => {
mapPanelProps.current = props
return <div data-testid="map" />
},
}))
vi.mock('../components/Collections/CopyToTripModal', () => ({ default: () => null }))
vi.mock('../components/Collections/MoveToListModal', () => ({ default: () => null }))
vi.mock('../components/Collections/ShareCollectionModal', () => ({ default: () => null }))
@@ -147,3 +153,17 @@ describe('CollectionsPage — the map survives a filter that matches nothing', (
expect(screen.queryByTestId('map')).toBeNull()
})
})
describe('CollectionsPage: a marker click shows the place (#2431)', () => {
it('selects the place a marker stands for, even when that place is already selected', () => {
const setSelectedPlaceId = vi.fn()
mockUseCollections.mockReturnValue(makeHook({ selectedPlaceId: 10, setSelectedPlaceId }))
render(<CollectionsPage />)
// A second click on the marker of the open place used to toggle it away, which
// read as the details refusing to open. The map background is what clears.
mapPanelProps.current!.onSelect(10)
expect(setSelectedPlaceId).toHaveBeenLastCalledWith(10)
mapPanelProps.current!.onDeselect()
expect(setSelectedPlaceId).toHaveBeenLastCalledWith(null)
})
})
+9 -5
View File
@@ -45,12 +45,16 @@ function CollectionsPageDesktop(): React.ReactElement {
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
const canManageLabels = isRealList && c.canEdit
// Selecting a place toggles it, so clicking it again — or the map background —
// clears it. Below the desktop breakpoint the list and map are separate views;
// above it the list view is a split with a persistent map that pans to the
// selection (the map stays mounted across the list↔map toggle so it animates).
// Selecting a place from the list toggles it, so clicking the row again clears
// it, as does the map background. A marker click always shows the place: a second
// click on the marker of the place that is open read as "the details do not open"
// rather than as "close them" (#2431). Below the desktop breakpoint the list and
// map are separate views; above it the list view is a split with a persistent map
// that pans to the selection (the map stays mounted across the list↔map toggle so
// it animates).
const mappable = c.mappable
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
const showPlace = (id: number) => c.setSelectedPlaceId(id)
const deselect = () => c.setSelectedPlaceId(null)
const toggleView = () => {
// Going to the full-map view closes the (list-docked) detail sheet.
@@ -59,7 +63,7 @@ function CollectionsPageDesktop(): React.ReactElement {
}
// Clicking a marker in the full-map view drops back to the split so the list
// + detail come into view alongside the map.
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
const onMapSelect = (id: number) => { showPlace(id); if (c.view === 'map') c.setView('list') }
const desktopSplit = c.isWide && c.hasMappable
const mapShown = c.hasMappable && (c.view === 'map' || c.isWide)
+39 -1
View File
@@ -1,4 +1,4 @@
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-010
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-023
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
@@ -580,4 +580,42 @@ describe('JourneyPublicPage', () => {
const badge = Array.from(shibuya.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''));
expect(badge?.textContent).toBe('1');
});
// The share route resolves a photo the way the journey's own does, so a clip that
// came up without a poster 404s here too, and an <img> pointed at it drew the
// broken-image glyph on every shared layout (#2341).
it('FE-PAGE-PUBLICJOURNEY-023: a shared clip without a poster is a black tile, with a poster its poster', async () => {
const user = userEvent.setup();
const bare = { id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.mp4', thumbnail_path: null, media_type: 'video', caption: null };
const withPoster = { id: 201, entry_id: 21, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.mp4', thumbnail_path: 'journey/b.jpg', media_type: 'video', caption: null };
const entry = (id: number, photo: typeof bare) => ({
id, title: `Clip ${id}`, story: null, entry_date: '2026-03-15', entry_time: null,
location_name: null, location_lat: null, location_lng: null, mood: null, weather: null, pros_cons: null,
photos: [photo],
});
server.use(http.get('/api/public/journey/test-share-token', () => HttpResponse.json({
...mockJourneyData,
entries: [entry(20, bare), entry(21, withPoster)],
gallery: [
{ ...bare, journey_id: 1, shared: 1, sort_order: 0, created_at: 0 },
{ ...withPoster, journey_id: 1, shared: 1, sort_order: 1, created_at: 0 },
],
stats: { entries: 2, photos: 2, places: 0 },
})));
const bareSrc = 'img[src="/api/public/journey/test-share-token/photos/200/thumbnail"]';
const posterSrc = 'img[src="/api/public/journey/test-share-token/photos/201/thumbnail"]';
render(<JourneyPublicPage />);
await waitFor(() => expect(screen.getByText('Clip 20')).toBeInTheDocument());
expect(document.querySelector(bareSrc)).toBeNull();
expect(document.querySelector(posterSrc)).toBeInTheDocument();
expect(document.querySelectorAll('svg.lucide-play')).toHaveLength(2);
await user.click(screen.getAllByRole('button').find(btn => /gallery/i.test(btn.textContent || ''))!);
await waitFor(() => expect(document.querySelectorAll('.grid svg.lucide-play')).toHaveLength(2));
expect(document.querySelector(bareSrc)).toBeNull();
expect(document.querySelector(posterSrc)).toBeInTheDocument();
});
});
+68 -40
View File
@@ -31,6 +31,7 @@ import EmptyState from '../components/shared/EmptyState';
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
import { useTranslation } from '../i18n';
import { formatLocationName } from '../utils/formatters';
import { posterlessVideo } from './journeyDetail/JourneyDetailPage.helpers';
import { useJourneyPublic } from './journeyPublic/useJourneyPublic';
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -73,6 +74,13 @@ function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail'
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`;
}
// The share route answers 404 for the thumbnail of a clip that has no poster, and
// an <img> pointed at it draws the broken-image glyph (#2341). The black ground
// the layouts give a video stands in for the picture.
function ClipTile() {
return <span className="block h-full w-full bg-black" />;
}
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00');
return {
@@ -251,12 +259,14 @@ export default function JourneyPublicPage() {
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
>
<div className={`relative h-64 w-full ${photos[0].media_type === 'video' ? 'bg-black' : ''}`}>
<img
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')}
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
alt=""
/>
{!posterlessVideo(photos[0]) && (
<img
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')}
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
alt=""
/>
)}
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -301,12 +311,14 @@ export default function JourneyPublicPage() {
}`}
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
>
<img
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
{!posterlessVideo(p) && (
<img
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -327,12 +339,16 @@ export default function JourneyPublicPage() {
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
>
<img
src={photoUrl(photos[0], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
{posterlessVideo(photos[0]) ? (
<ClipTile />
) : (
<img
src={photoUrl(photos[0], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
</button>
<div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}>
<button
@@ -340,24 +356,32 @@ export default function JourneyPublicPage() {
className="min-h-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}
>
<img
src={photoUrl(photos[1], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
{posterlessVideo(photos[1]) ? (
<ClipTile />
) : (
<img
src={photoUrl(photos[1], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
</button>
<button
type="button"
className="relative min-h-0 flex-1 cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}
>
<img
src={photoUrl(photos[2], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
{posterlessVideo(photos[2]) ? (
<ClipTile />
) : (
<img
src={photoUrl(photos[2], token!, 'thumbnail')}
alt=""
className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/>
)}
{photos.length > 3 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<span className="flex items-center gap-1 text-[13px] font-semibold text-white">
@@ -529,15 +553,19 @@ export default function JourneyPublicPage() {
})
}
>
<img
src={photoUrl(photo, token!, 'thumbnail')}
className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
? 'object-contain bg-black'
: 'object-cover'
}`}
alt=""
loading="lazy"
/>
{posterlessVideo(photo) ? (
<ClipTile />
) : (
<img
src={photoUrl(photo, token!, 'thumbnail')}
className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
? 'object-contain bg-black'
: 'object-cover'
}`}
alt=""
loading="lazy"
/>
)}
{photo.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
@@ -825,7 +853,7 @@ export default function JourneyPublicPage() {
dark={document.documentElement.classList.contains('dark')}
readOnly
onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/thumbnail`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
cartoApiKey={cartoApiKey}
showMood={journey.show_mood !== 0}
@@ -269,8 +269,11 @@ describe('JourneyPublicPage wiring', () => {
const { hook } = setup({ isMobile: true, desktopTwoColumn: false });
expect(screen.getByTestId('mobile-timeline')).toBeInTheDocument();
// The cover card draws this URL in an <img>, so it has to be the poster, not
// the file itself: /original is the clip for a video and a full-size JPEG for
// a photo (#2341). The own-journey cover already asks for /thumbnail.
const publicPhotoUrl = mocks.captured.mobileTimeline.publicPhotoUrl as (id: number) => string;
expect(publicPhotoUrl(55)).toBe('/api/public/journey/tok-1/photos/55/original');
expect(publicPhotoUrl(55)).toBe('/api/public/journey/tok-1/photos/55/thumbnail');
(mocks.captured.mobileTimeline.onEntryClick as (e: PublicEntry) => void)(buildEntry({ id: 9 }));
expect(hook.setViewingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 9 }));
+47 -8
View File
@@ -16,7 +16,9 @@ vi.mock('react-leaflet', () => ({
),
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-tiles" data-url={url} />,
Marker: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Polyline: () => <div data-testid="route-line" />,
Polyline: ({ positions }: { positions: [number, number][] }) => (
<div data-testid="route-line" data-positions={JSON.stringify(positions)} />
),
Tooltip: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
useMap: () => ({
fitBounds: vi.fn(),
@@ -60,6 +62,10 @@ function renderSharedTrip(token: string) {
);
}
// Leaflet is mocked, so every marker the last render drew is a recorded divIcon call.
const divIconMock = () => L.divIcon as unknown as ReturnType<typeof vi.fn>;
const iconHtml = () => divIconMock().mock.calls.map(c => String(c[0].html));
beforeEach(() => {
// SharedTripPage does NOT require authentication — do NOT seed auth store
resetAllStores();
@@ -818,10 +824,7 @@ describe('SharedTripPage', () => {
const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null };
const notre = { id: 203, name: 'Notre-Dame', lat: 48.853, lng: 2.35, category: null };
// Every divIcon call the last render produced, as raw html strings.
const iconHtml = () => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
beforeEach(() => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear());
beforeEach(() => divIconMock().mockClear());
it('numbers the stops by order_index, not by payload order', async () => {
await open('order-map-token', payload({
@@ -966,15 +969,15 @@ describe('SharedTripPage', () => {
// The payload nests the category on a day's assignments but sends it flat on the
// trip-wide pool, so reading only the nested shape painted every marker indigo.
it('reads the flat category_color the trip pool sends', async () => {
(L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear();
divIconMock().mockClear();
await open('flatcat-token', payload({
days: [],
places: [{ id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category_color: '#ff8800', category_icon: 'landmark' }],
assignments: {},
}));
await waitFor(() => expect((L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0));
const html = (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
await waitFor(() => expect(iconHtml().length).toBeGreaterThan(0));
const html = iconHtml();
expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true);
expect(html.some((h: string) => h.includes('#6366f1'))).toBe(false);
});
@@ -1391,6 +1394,42 @@ describe('SharedTripPage', () => {
// The booked night is not: it is already on the day as its own chip.
expect(screen.queryByText('Unter den Linden 77')).toBeNull();
});
it('numbers the map and draws the day line over the traveller\'s stops only, and keeps the hotel as an unnumbered pin', async () => {
// Since the reseat the booked night heads its day, so counting it gave the hotel
// badge 1, every real stop one more than the app shows, and a line setting off
// from where the day ends.
divIconMock().mockClear();
const hotel = { ...place(602, 'Hotel Adlon', 'Unter den Linden 77'), lat: 52.52, lng: 13.38 };
const rodin = { ...place(601, 'Musee Rodin', '77 Rue de Varenne'), lat: 48.86, lng: 2.32 };
const orsay = { ...place(603, 'Musee d Orsay', '1 Rue de la Legion d Honneur'), lat: 48.86, lng: 2.33 };
await open('stay-map-token', payload({
days,
assignments: {
41: [
{ id: 402, day_id: 41, order_index: 0, notes: null, accommodation_id: 7, place: hotel },
{ id: 401, day_id: 41, order_index: 1, notes: null, accommodation_id: null, place: rodin },
{ id: 403, day_id: 41, order_index: 2, notes: null, accommodation_id: null, place: orsay },
],
},
accommodations: [{ id: 7, place_id: 602, start_day_id: 41, end_day_id: 41, place_name: 'Hotel Adlon' }],
}));
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1<'))).toBe(true));
// Rodin is 1 and Orsay is 2, as in the planner; nothing wears 3 and the hotel's
// pin is still drawn, without a badge.
const html = iconHtml();
expect(html.filter((h: string) => h.includes('>1<'))).toHaveLength(1);
expect(html.filter((h: string) => h.includes('>2<'))).toHaveLength(1);
expect(html.some((h: string) => h.includes('>3<'))).toBe(false);
expect(html.filter((h: string) => !h.includes('<span'))).toHaveLength(1);
// The day line joins the two stops and does not start at the hotel.
const line = screen.getByTestId('route-line');
expect(JSON.parse(line.getAttribute('data-positions') ?? '[]')).toEqual([[48.86, 2.32], [48.86, 2.33]]);
});
});
// ── #2320: the richer read-only detail ─────────────────────────────────
+22 -11
View File
@@ -167,22 +167,33 @@ export default function SharedTripPage() {
// trip-wide pool has none (it arrives by created_at). The index runs over the full
// sorted assignment list, like the planner does, so a stop without coordinates still
// consumes a number and the app and the share link agree on what "3" means.
//
// The stop a booked night wrote onto its check-in day heads that day since the
// reseat, and the planner's numbers leave it out (the day list below does too). So
// the numbers and the day line are counted over the traveller's own stops, or the
// hotel wore badge 1, every real stop read one higher than in the app, and the line
// set off from where the day ends. Its pin stays on the day, unnumbered, the way the
// planner keeps the hotel on the map.
const dayAssignments = selectedDay
? [...(assignments[String(selectedDay)] || [])].sort((a: any, b: any) => a.order_index - b.order_index)
: [];
const dayStops = dayAssignments.filter(a => a.accommodation_id == null);
const dayOrderMap: Record<number, number[]> = {};
dayAssignments.forEach((a: any, i: number) => {
dayStops.forEach((a, i) => {
if (!a.place?.id) return;
(dayOrderMap[a.place.id] ||= []).push(i + 1);
});
const dayPlaces: any[] = [];
const seenPlaceIds = new Set<number>();
for (const a of dayAssignments as any[]) {
const p = a.place;
if (!p?.lat || !p?.lng || seenPlaceIds.has(p.id)) continue;
seenPlaceIds.add(p.id);
dayPlaces.push(p);
}
// The places these assignments sit on, in their order, each drawn once.
const locatedPlaces = (list: typeof dayAssignments) => {
const seen = new Set<number>();
return list.map(a => a.place).filter(p => {
if (!p?.lat || !p?.lng || seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
};
const dayPlaces = locatedPlaces(dayAssignments);
const dayLine = locatedPlaces(dayStops);
const mapPlaces = selectedDay ? dayPlaces : (places || []).filter((p: any) => p?.lat && p?.lng);
// Open framed on the trip's places instead of on Paris. MapContainer only reads center/zoom
@@ -451,9 +462,9 @@ export default function SharedTripPage() {
/>
)}
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
{selectedDay && mapPlaces.length > 1 && (
{selectedDay && dayLine.length > 1 && (
<Polyline
positions={mapPlaces.map((p: any) => [p.lat, p.lng])}
positions={dayLine.map(p => [p.lat, p.lng])}
// Dashed and straight on purpose: it shows the order of the day's stops,
// not the roads between them. A real route would mean sending the
// itinerary to a third party for every anonymous visitor of a shared
+87 -1
View File
@@ -3,7 +3,7 @@ import React from 'react';
import { render, screen, waitFor, act, fireEvent } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment } from '../../tests/helpers/factories';
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import { usePluginStore } from '../store/pluginStore';
@@ -173,6 +173,34 @@ vi.mock('../components/Trips/TripMembersModal', () => ({
},
}));
// The road-trip rail and the booking dialog it brings along (#2428). Both capture their
// props so a case can drive the rail's handlers and read what the page did with them.
type RoadtripSidebarStubProps = { onOpenBooking?: (reservationId: number) => void; canEditBookings?: boolean };
const capturedRoadtripSidebarProps: { current: RoadtripSidebarStubProps } = { current: {} };
vi.mock('../components/Roadtrip/RoadtripSidebar', () => ({
default: (props: RoadtripSidebarStubProps) => {
capturedRoadtripSidebarProps.current = props;
return React.createElement('div', { 'data-testid': 'roadtrip-sidebar' });
},
}));
type TransportDetailStubProps = { transportDetail?: { id: number } | null };
const capturedTransportDetailModalProps: { current: TransportDetailStubProps } = { current: {} };
vi.mock('../components/Planner/DayPlanSidebarTransportDetailModal', () => ({
DayPlanSidebarTransportDetailModal: (props: TransportDetailStubProps) => {
capturedTransportDetailModalProps.current = props;
return null;
},
}));
vi.mock('../components/Roadtrip/RoadtripCorridorPanel', () => ({
default: () => React.createElement('div', { 'data-testid': 'roadtrip-corridor-panel' }),
}));
vi.mock('../components/Roadtrip/RoadtripLimitsCard', () => ({
default: () => null,
}));
// Configurable usePlaceSelection mock — lets tests set a specific selected place
const mockPlaceSelectionState: { selectedPlaceId: number | null; selectedAssignmentId: number | null } = {
selectedPlaceId: null,
@@ -253,6 +281,8 @@ beforeEach(() => {
capturedTripMembersModalProps.current = {};
capturedFileManagerProps.current = {};
capturedPlaceInspectorProps.current = {};
capturedRoadtripSidebarProps.current = {};
capturedTransportDetailModalProps.current = {};
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
@@ -1709,4 +1739,60 @@ describe('TripPlannerPage', () => {
});
});
});
describe('FE-PAGE-PLANNER-052: Road trip mode opens a booking the way the day plan does (#2428)', () => {
// The rail is mounted only with the addon on and the mode on for this trip; the
// road-trip hooks then read their own endpoints, answered empty here.
const enterRoadtrip = () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({ addons: [{ id: 'roadtrip', type: 'roadtrip' }] })),
http.get('/api/trips/42/roadtrip/vias', () => HttpResponse.json({ vias: [], tracks: [] })),
http.get('/api/trips/42/roadtrip/preferences', () => HttpResponse.json({ tripId: 42, preferences: {} })),
);
sessionStorage.setItem('trip-roadtrip-42', '1');
};
it('a flight opens the transport detail view the rail brings along, a table opens its editor', async () => {
enterRoadtrip();
vi.useFakeTimers();
seedTripStore({ id: 42 });
const flight = buildReservation({ id: 70, trip_id: 42, type: 'flight', title: 'LH 2020' });
const table = buildReservation({ id: 11, trip_id: 42, type: 'restaurant', title: 'Tisch Bullerei' });
seedStore(useTripStore, { reservations: [flight, table] });
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('roadtrip-sidebar')).toBeInTheDocument();
});
expect(screen.queryByTestId('day-plan-sidebar')).not.toBeInTheDocument();
// Without this the rail cannot tell a chip that opens from one that does not, and
// every table and ticket becomes a button that no-ops.
expect(capturedRoadtripSidebarProps.current.canEditBookings).toBe(true);
// A terminal row, a ride pill and a map endpoint all set the booking to show, and
// under Days the day panel owns the dialog that shows it. Here it has to be the
// rail's own copy, or nothing shows.
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(70); });
await waitFor(() => {
expect(capturedTransportDetailModalProps.current.transportDetail).toMatchObject({ id: 70 });
});
expect(capturedReservationModalProps.current.isOpen).toBe(false);
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(11); });
await waitFor(() => {
expect(capturedReservationModalProps.current.isOpen).toBe(true);
});
expect(capturedReservationModalProps.current.reservation).toMatchObject({ id: 11 });
// A booking the trip does not hold opens nothing.
act(() => { capturedRoadtripSidebarProps.current.onOpenBooking?.(999); });
expect(capturedTransportDetailModalProps.current.transportDetail).toMatchObject({ id: 70 });
});
});
});
+28 -3
View File
@@ -10,6 +10,7 @@ import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map
import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import { DayPlanSidebarTransportDetailModal } from '../components/Planner/DayPlanSidebarTransportDetailModal'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -254,7 +255,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
// Page = wiring container: the entire planner state machine (store, tabs,
// selection, CRUD handlers with undo, map filters, splash) lives in the hook.
const {
tripId, navigate, toast, t, language, placesPhotosEnabled,
tripId, navigate, toast, t, language, locale, settings, placesPhotosEnabled,
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
@@ -296,7 +297,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
stayRelease, setStayRelease, confirmStayRelease,
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
@@ -445,7 +446,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={true}
visibleConnectionIds={visibleConnections}
// In road trip mode the rides that seam the drive are drawn as their own arcs
// beside the roads, on top of what the reader switched on under Days.
visibleConnectionIds={roadtripActive ? roadtripConnections : visibleConnections}
onReservationClick={(rid) => {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
@@ -596,6 +599,16 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onFocusPoint={focusRoadtripPoint}
selectedAssignmentId={selectedAssignmentId}
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
reservations={reservations}
onOpenBooking={(rid) => {
const r = reservations.find(x => x.id === rid)
if (!r) return
// The day plan's own split: a transport has a detail view with
// an edit button on it, a table or a ticket only has its editor.
if (TRANSPORT_TYPES.has(r.type)) setMapTransportDetail(r)
else openLinkedReservation?.(r)
}}
canEditBookings={can('reservation_edit', trip)}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
@@ -612,6 +625,18 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
collapsedDayIds={collapsedRoadtripDays}
onToggleDay={toggleRoadtripDay}
/>
{/* The booking a terminal, a ride pill or a map endpoint opens. Under
Days the day panel owns this dialog; here the day panel is not
mounted, so the rail has to bring it along (#2428). */}
<DayPlanSidebarTransportDetailModal
transportDetail={mapTransportDetail}
setTransportDetail={setMapTransportDetail}
onNavigateToFiles={() => handleTabChange('dateien')}
onEdit={can('day_edit', trip) ? (reservation) => { setMapTransportDetail(null); setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
t={t}
locale={locale}
timeFormat={settings.time_format || '24h'}
/>
</LazyPanel>
) : (
<DayPlanSidebar
@@ -435,6 +435,30 @@ describe('AdminSettingsTab', () => {
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment');
});
it('FE-ADMSET-030d: the Google-only switch sits with the Google options, says what it needs without a key, and toggles through the hook', () => {
const admin = renderTab({ placesGoogleOnly: false, hasMapsKey: false });
expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument();
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(toggleFor('Search with Google only'));
expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1);
});
it('FE-ADMSET-030e: with a key the Google-only row explains what it changes', () => {
renderTab({ placesGoogleOnly: true, hasMapsKey: true });
expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument();
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'true');
});
it('FE-ADMSET-030f: with a key but Amap or OpenStreetMap picked, the Google-only row says the switch is idle', () => {
// A key alone does not put Google behind the search; the server ignores the
// switch under another provider, and the row must not promise otherwise.
renderTab({ placesGoogleOnly: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
const hint = toggleFor('Search with Google only').closest<HTMLElement>('.flex.items-center.justify-between')!.querySelectorAll('p')[1];
expect(hint.textContent).not.toMatch(/Every search and every suggestion goes to Google Places/);
expect(hint.textContent).not.toMatch(/Needs a Google Maps API key/);
expect(hint.textContent).toMatch(/provider/i);
});
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
renderTab();
+20 -2
View File
@@ -7,6 +7,7 @@ import CustomSelect from '../../components/shared/CustomSelect'
import GoogleOptions from './GoogleOptions'
import ProviderBlock from './ProviderBlock'
import TrekApiCard from './TrekApiCard'
import { placesGoogleOnlyHint } from '../../utils/placeSource'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
@@ -25,6 +26,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
transitProvider, setTransitProviderState,
transitGoogleKeySource, setTransitGoogleKeySource,
placeShadowEnabled, setPlaceShadowEnabledState,
@@ -448,8 +450,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<GoogleOptions
title={t('admin.googleOptions')}
summary={t('admin.googleOptionsSummary', {
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled].filter(Boolean).length,
total: 4,
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled, placesGoogleOnly].filter(Boolean).length,
total: 5,
})}
>
<div className="divide-y divide-edge-faint">
@@ -520,6 +522,22 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
}}
/>
</div>
{/* The one row here that is about where a search goes rather than
what the key may be spent on. Without a key, or with Amap or
OpenStreetMap holding the slot, it is a promise the search
cannot keep, and the subtitle says so. */}
<div className="flex items-center justify-between gap-4 py-3">
<div>
<p className="text-sm font-medium text-content-secondary">{t('admin.placesGoogleOnly.title')}</p>
<p className="text-xs text-content-faint mt-0.5">{t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}</p>
</div>
<ToggleSwitch
on={placesGoogleOnly}
label={t('admin.placesGoogleOnly.title')}
onToggle={handleTogglePlacesGoogleOnly}
/>
</div>
</div>
</GoogleOptions>
</ProviderBlock>
+2 -2
View File
@@ -4,7 +4,7 @@ import { ChevronRight } from 'lucide-react'
interface GoogleOptionsProps {
/** "Google options" — the heading of the fold. */
title: string
/** Right-hand summary while folded, e.g. "3 of 4 on". */
/** Right-hand summary while folded, e.g. "4 of 5 on". */
summary: string
children: React.ReactNode
}
@@ -15,7 +15,7 @@ interface GoogleOptionsProps {
* Only these switches fold away. The key fields above stay visible: a field you
* have to go looking for is worse than a long page, and the keys are the reason
* an admin opens this card at all. The switches are the opposite — set once,
* then never touched, and there are four of them.
* then never touched, and there are five of them.
*
* A native <details> rather than a state hook: keyboard operable and announced
* to screen readers without writing any of that, and it survives a re-render
+33 -1
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-049
// FE-ADMHOOK-001 to FE-ADMHOOK-051
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -276,6 +276,38 @@ describe('useAdmin', () => {
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
});
it('FE-ADMHOOK-050: handleTogglePlacesGoogleOnly flips the switch and PUTs it', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/places-google-only', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ enabled: true });
})
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleTogglePlacesGoogleOnly();
});
expect(body).toEqual({ enabled: true });
expect(result.current.placesGoogleOnly).toBe(true);
});
it('FE-ADMHOOK-051: a rejected Google-only update rolls the switch back and toasts', async () => {
server.use(
http.put('/api/admin/places-google-only', () => HttpResponse.json({ error: 'locked' }, { status: 500 }))
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleTogglePlacesGoogleOnly();
});
expect(result.current.placesGoogleOnly).toBe(false);
expect(toastCalls).toContainEqual({ type: 'error', message: 'locked' });
});
it('FE-ADMHOOK-016: handleSaveWebauthn trims and re-reads the app config', async () => {
let body: Record<string, unknown> | null = null;
server.use(
+21
View File
@@ -105,6 +105,11 @@ export function useAdmin() {
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
useEffect(() => { adminApi.getPlacesEnrich().then(d => setPlacesEnrichEnabledState(d.enabled)).catch(() => {}) }, [])
// Search and suggestions from Google alone. Admin-only state: the search itself
// reads the switch on the server, so nothing else in the client needs it.
const [placesGoogleOnly, setPlacesGoogleOnlyState] = useState<boolean>(false)
useEffect(() => { adminApi.getPlacesGoogleOnly().then(d => setPlacesGoogleOnlyState(d.enabled)).catch(() => {}) }, [])
// Transit backend (#1699). googleKeySource says where a Google key would come
// from for this admin — null means picking Google changes nothing, since the
// request-time fallback to Transitous is silent by design.
@@ -192,6 +197,9 @@ export function useAdmin() {
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, logout } = useAuthStore()
// The store's copy is what the search screens gate the "search Google
// instead" line on, so a saved choice lands there the way a saved key does.
const setStorePlacesProvider = useAuthStore(s => s.setPlacesProvider)
const navigate = useNavigate()
const toast = useToast()
@@ -354,12 +362,24 @@ export function useAdmin() {
}
}
const handleTogglePlacesGoogleOnly = async () => {
const next = !placesGoogleOnly
setPlacesGoogleOnlyState(next)
try {
await adminApi.updatePlacesGoogleOnly(next)
} catch (err: unknown) {
setPlacesGoogleOnlyState(!next)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleSavePlacesProvider = async (value: string) => {
const previous = placesProvider
setPlacesProvider(value)
setSavingPlacesProvider(true)
try {
await authApi.updateAppSettings({ places_provider: value })
setStorePlacesProvider(value)
toast.success(t('admin.placesProvider.saved'))
} catch (err: unknown) {
setPlacesProvider(previous)
@@ -481,6 +501,7 @@ export function useAdmin() {
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
transitProvider, setTransitProviderState,
transitGoogleKeySource, setTransitGoogleKeySource,
placeShadowEnabled, setPlaceShadowEnabledState,
@@ -4,6 +4,7 @@ import {
distanceBetweenGeoPoints,
groupPhotosByDate,
matchJourneyEntries,
posterlessVideo,
sortProviderPhotos,
utcOffsetMinutesForDay,
} from './JourneyDetailPage.helpers';
@@ -207,3 +208,20 @@ describe('matchJourneyEntries', () => {
expect(matchJourneyEntries(entries, 'reykjavik')).toEqual([]);
});
});
describe('posterlessVideo (#2341)', () => {
it('is a local clip whose poster frame was never grabbed', () => {
expect(posterlessVideo({ media_type: 'video', provider: 'local', thumbnail_path: null })).toBe(true);
expect(posterlessVideo({ media_type: 'video', provider: 'local' })).toBe(true);
});
it('is not a clip that has its poster, whatever the file is called', () => {
expect(posterlessVideo({ media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' })).toBe(false);
});
it('is never a photo, and never a provider clip, whose poster the provider serves', () => {
expect(posterlessVideo({ media_type: 'image', provider: 'local', thumbnail_path: null })).toBe(false);
expect(posterlessVideo({ provider: 'local', thumbnail_path: null })).toBe(false);
expect(posterlessVideo({ media_type: 'video', provider: 'immich', thumbnail_path: null })).toBe(false);
});
});
@@ -73,6 +73,21 @@ export function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original'
return `/api/photos/${p.photo_id}/${size}`;
}
/**
* A clip that came up without a poster frame: the browser could not decode it
* at upload time, so nothing was grabbed. Its thumbnail route answers 404 on
* purpose rather than streaming the whole file, and an <img> pointed at it
* shows the broken-image glyph (#2341). A provider clip is not one of these,
* its poster comes from the provider whether or not a local path is recorded.
*/
export function posterlessVideo(p: {
media_type?: string | null;
provider?: string | null;
thumbnail_path?: string | null;
}): boolean {
return p.media_type === 'video' && p.provider === 'local' && !p.thumbnail_path;
}
/**
* Which calendar day a provider photo belongs to.
*
@@ -30,6 +30,9 @@ export interface PublicPhoto {
caption?: string | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
/** Null on a local clip whose poster frame could not be grabbed; the page
* draws a marked tile instead of an <img> then (#2341). */
thumbnail_path?: string | null
duration_ms?: number | null
}
@@ -44,6 +47,9 @@ export interface PublicGalleryPhoto {
caption?: string | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
/** Null on a local clip whose poster frame could not be grabbed; the gallery
* draws a marked tile instead of an <img> then (#2341). */
thumbnail_path?: string | null
duration_ms?: number | null
/** Where and when the picture was taken (#1614). Absent for most photos, and the
* coordinates are withheld entirely unless the owner shared the map. */
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import type { JourneyMapHandle } from '../../components/Journey/JourneyMap'
import { useIsMobile } from '../../hooks/useIsMobile'
import { DAY_COLORS } from '../../components/Journey/dayColors'
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
/**
@@ -86,10 +87,14 @@ export function useJourneyPublic() {
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
// Through the share token, like every other picture on this page: the
// reader has no session, so `/api/photos/:id` would answer 401.
// reader has no session, so `/api/photos/:id` would answer 401. The
// poster rather than the file, as the owner's own map card does, since
// /original is the whole clip for a video; a clip that came up without
// a poster is dropped instead of holding a blank slot (#2341).
photoUrls: (e.photos ?? [])
.filter(p => !posterlessVideo(p))
.slice(0, 3)
.map((p: { photo_id: number }) => `/api/public/journey/${token}/photos/${p.photo_id}/original`),
.map(p => `/api/public/journey/${token}/photos/${p.photo_id}/thumbnail`),
}
})
}, [mapEntries, sortedDates, token])
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
// FE-TP-ROAD-001 to FE-TP-ROAD-108
// FE-TP-ROAD-001 to FE-TP-ROAD-112
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -2422,3 +2422,69 @@ describe('useTripPlanner road trip: a hit handed to the full form', () => {
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 6, 900, null)
})
})
describe('useTripPlanner road trip: a ride on the drive (#2428)', () => {
/**
* One card: Hamburg, the airport it flies from, the airport it lands at, Munich. The
* ride draws no line of its own, so the card's geometry jumps from one terminal to the
* other and a click under the flight path projects onto that jump. The terminals carry
* the index of the stop after them, as `seatCarrierStops` seats them: they stand in
* for no stored stop of their own.
*/
const terminal = (role: 'departure' | 'arrival') => ({
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
placeId: -70,
ownerIndex: 1,
lat: role === 'departure' ? 53.63 : 48.35,
lng: role === 'departure' ? 9.99 : 11.78,
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: role === 'departure' ? 'HAM' : 'MUC', at: null },
})
const flownDay = () => {
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
rt.corridor.day = { dayId: 5, dayNumber: 1 }
rt.routes.days = [{
dayId: 5,
dayNumber: 1,
stops: [{ assignmentId: 1, placeId: 10, lat: 53.55, lng: 9.99 }, terminal('departure'), terminal('arrival'), { assignmentId: 2, placeId: 20, ownerIndex: 1, lat: 48.13, lng: 11.58 }],
geometry: [[53.55, 9.99], [53.63, 9.99], [48.35, 11.78], [48.13, 11.58]],
}]
}
it('FE-TP-ROAD-109: a via is filed on the road into the departure terminal, never on the ride or the road out of the arrival', async () => {
flownDay()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(53.59, 9.99) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.59, 9.99)
rt.vias.add.mockClear()
// Under the flight path, and on the road out of the airport it lands at: a via there
// would be stored at the next stop's position and bend that stop's road instead.
await act(async () => { await result.current.addRoadtripVia(50.99, 10.885) })
await act(async () => { await result.current.addRoadtripVia(48.24, 11.68) })
expect(rt.vias.add).not.toHaveBeenCalled()
})
it('FE-TP-ROAD-110: a via dragged onto the road out of the arrival moves without an anchor', async () => {
flownDay()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.moveRoadtripVia(5, 3, 48.24, 11.68) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 48.24, 11.68, undefined)
})
it('FE-TP-ROAD-111: nothing is stopped at on a flight; behind the arrival the road goes on', async () => {
flownDay()
const { result } = await renderRoadtrip()
expect(result.current.manualStopTargetFor(50.99, 10.885)).toBeNull()
expect(result.current.manualStopTargetFor(48.24, 11.68)).toMatchObject({ dayId: 5 })
})
it('FE-TP-ROAD-112: the ride is drawn on the map as its booking, on top of whatever the reader switched on', async () => {
flownDay()
const { result } = await renderRoadtrip()
expect(result.current.roadtripConnections).toContain(70)
})
})
@@ -2092,6 +2092,21 @@ describe('useTripPlanner — misc state', () => {
expect(result.current.prefillCoords).toBeNull()
})
it('FE-TP-HOOK-120: without place_edit rights the place editor and the delete dialog stay shut (#2446)', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 2, role: 'user' }) })
usePermissionsStore.setState({ permissions: { place_edit: 'admin' } })
const place = buildPlace({ id: 1, lat: 1, lng: 2 })
seedTrip({ places: [place], assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place })] } })
const { result } = await renderPlanner()
act(() => { result.current.openPlaceEditor(place, 10) })
act(() => { result.current.handleDeletePlace(1) })
expect(result.current.showPlaceForm).toBe(false)
expect(result.current.editingPlace).toBeNull()
expect(result.current.deletePlaceId).toBeNull()
})
it('FE-TP-HOOK-102: a member roster refresh replaces the cached list', async () => {
seedTrip()
const { result } = await renderPlanner()
+36 -13
View File
@@ -14,6 +14,7 @@ import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { getDayOrder } from '../../utils/dayOrder'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { isOvernightCategory } from '../../components/Roadtrip/stopKinds'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared'
@@ -30,6 +31,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { carrierReservationIds, type CarrierTerminal } from '@trek/shared/roadtrip'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
@@ -76,6 +78,7 @@ import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlace
/** Stable empty list so the road trip hook stays inert while its mode is off. */
const EMPTY_DAYS: Day[] = []
const EMPTY_RESERVATIONS: Reservation[] = []
/**
* Trip planner page logic — the big one. Owns the trip store wiring, addon
@@ -93,7 +96,7 @@ export function useTripPlanner() {
const tripId = id ? Number(id) : Number.NaN
const navigate = useNavigate()
const toast = useToast()
const { t, language } = useTranslation()
const { t, language, locale } = useTranslation()
const { settings } = useSettingsStore()
const roadtripSettings = useRoadtripSettings(s => s, tripId)
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
@@ -213,7 +216,6 @@ export function useTripPlanner() {
}).catch(() => {})
}, [])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
@@ -743,7 +745,18 @@ export function useTripPlanner() {
roadtripVias.byDay,
dayBoundaries.boundaries,
tripAccommodations,
roadtripFeedActive ? reservations : EMPTY_RESERVATIONS,
)
/**
* The bookings the drive is seamed by, drawn as their own arcs beside the roads: a
* flight's route on the road trip map is the booking's line, the same one the day plan
* draws, so the ride shows where the road stops (#2428). On top of whatever the reader
* switched on by hand under Days.
*/
const roadtripConnections = useMemo(() => {
const rides = carrierReservationIds(roadtripRoutes.days)
return rides.length ? [...new Set([...visibleConnections, ...rides])] : visibleConnections
}, [roadtripRoutes.days, visibleConnections])
// Lives here rather than in the panel because the map draws what it finds.
// The trip comes with it for the vehicle: an electric car looks for chargers rather
// than for pumps, and that preference is stored per trip.
@@ -1646,7 +1659,11 @@ export function useTripPlanner() {
* looks exactly like a drag that did nothing.
*/
const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => {
let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null
// `terminal` names the anchor when it is one end of a ride rather than a stored stop
// (#2428). Nothing can be filed against a terminal: it stands in for no assignment,
// so a via anchored to it would be stored at a position that belongs to the stop
// after it and bend that stop's road instead. The callers decide what to refuse.
let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: CarrierTerminal['role'] | null } | null = null
for (const day of roadtripRoutes.days) {
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
// on, but that day's stops are no longer all on the card of the same name: after a
@@ -1682,7 +1699,7 @@ export function useTripPlanner() {
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = arrivedFrom.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue
best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm, terminal: arrivedFrom.carrier?.role ?? null }
continue
}
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
@@ -1699,7 +1716,7 @@ export function useTripPlanner() {
const owner = anchor.ownerDayId ?? day.dayId
// A drag stays on its own day; a fresh click may land wherever it landed.
if (onlyDayId !== undefined && owner !== onlyDayId) continue
best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm }
best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm, terminal: anchor.carrier?.role ?? null }
}
return best
}, [roadtripRoutes.days])
@@ -1720,7 +1737,9 @@ export function useTripPlanner() {
*/
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
const anchor = anchorFor(lat, lng)
if (!anchor) return null
// Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk
// is a road, and a stop there is the first stop after landing or after the pick-up.
if (!anchor || anchor.terminal === 'departure') return null
for (const day of roadtripRoutes.days) {
const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm }
@@ -1805,8 +1824,10 @@ export function useTripPlanner() {
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
if (!can('day_edit', trip)) return
const best = anchorFor(lat, lng)
// A click that landed on some other line is not a via anywhere.
if (!best || best.offRouteKm > 2) return
// A click that landed on some other line is not a via anywhere. Neither is one on a
// ride, or on the road out of a terminal: a via is filed by the position of a stored
// stop, and a terminal is not one.
if (!best || best.offRouteKm > 2 || best.terminal) return
try {
await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
} catch (err: unknown) {
@@ -1826,7 +1847,7 @@ export function useTripPlanner() {
// just says which leg gets bent, and the router answers the rest.
const anchor = anchorFor(lat, lng, dayId)
try {
await roadtripVias.move(dayId, id, lat, lng, anchor?.afterIndex)
await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal ? anchor.afterIndex : undefined)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
@@ -2074,6 +2095,7 @@ export function useTripPlanner() {
// place's lone assignment to hydrate & persist its times; with 0 or 2+
// assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
if (!can('place_edit', trip)) return
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId))
@@ -2098,7 +2120,7 @@ export function useTripPlanner() {
setPlaceFormDayId(null)
setServiceStopForm(false)
setShowPlaceForm(true)
}, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
}, [can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
/**
* How long the drive stands here, for every stop alike.
@@ -2113,8 +2135,9 @@ export function useTripPlanner() {
}, [])
const handleDeletePlace = useCallback((placeId) => {
if (!can('place_edit', trip)) return
setDeletePlaceId(placeId)
}, [])
}, [can, trip])
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
@@ -2592,7 +2615,7 @@ export function useTripPlanner() {
}, [isLoading, trip])
return {
tripId, navigate, toast, t, language, settings, placesPhotosEnabled,
tripId, navigate, toast, t, language, locale, settings, placesPhotosEnabled,
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
@@ -2642,7 +2665,7 @@ export function useTripPlanner() {
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
+7
View File
@@ -52,6 +52,10 @@ interface AuthState {
* into one "has a search key": which of the two is missing decides what the
* admin has to go and do. */
hasAmapKey: boolean
/** The admin's places provider choice, as app-config normalises it: 'auto',
* 'google', 'amap' or 'openstreetmap'. Read with hasMapsKey to tell whether
* a search can reach Google at all (utils/placeSource googleHoldsSlot). */
placesProvider: string
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
@@ -81,6 +85,7 @@ interface AuthState {
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setHasAmapKey: (val: boolean) => void
setPlacesProvider: (val: string) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
@@ -132,6 +137,7 @@ export const useAuthStore = create<AuthState>()(
appVersion: '',
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
@@ -402,6 +408,7 @@ export const useAuthStore = create<AuthState>()(
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
setPlacesProvider: (val: string) => set({ placesProvider: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
+20 -8
View File
@@ -194,6 +194,20 @@ async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void
// ── Zustand event reducer ─────────────────────────────────────────────────────
/**
* A day with `incoming` seated where the server put it, the rows around it renumbered
* the way the local move reducer renumbers them. A booked night lands at the front of
* its day and the rows behind it were moved up one there; appended with that index it
* would sit tied with the old first row and be drawn second until the next reload. An
* index past the end, or none at all, appends.
*/
function seatByOrder(day: Assignment[], incoming: Assignment): Assignment[] {
const ordered = day.slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const at = Math.max(0, Math.min(incoming.order_index ?? ordered.length, ordered.length))
ordered.splice(at, 0, incoming)
return ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i }))
}
type StateApplier = (payload: Record<string, unknown>, state: TripStoreState) => Partial<TripStoreState>
/**
@@ -252,13 +266,8 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
}
// Genuinely new — including a legitimate second assignment of a place
// already on this day (no temp version to reconcile). Append.
return {
assignments: {
...state.assignments,
[dayKey]: [...existing, incoming],
}
}
// already on this day (no temp version to reconcile).
return { assignments: { ...state.assignments, [dayKey]: seatByOrder(existing, incoming) } }
},
'assignment:updated': (payload, state) => {
const dayKey = String((payload.assignment as Assignment).day_id)
@@ -284,11 +293,14 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
const oldKey = String(payload.oldDayId)
const newKey = String(payload.newDayId)
const movedAssignment = payload.assignment as Assignment
// The target day is read after the row has left it, so a night re-seated on its
// own day (oldKey === newKey) is taken out first and put back where it now sits.
const target = (state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id)
return {
assignments: {
...state.assignments,
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
[newKey]: seatByOrder(target, movedAssignment),
}
}
},
+38 -7
View File
@@ -187,6 +187,10 @@
.trek-dash .hero-content {
position: absolute; inset: 0; z-index: 2; padding: 32px 32px 26px;
display: grid; grid-template-rows: auto 1fr auto; color: #fff;
/* The one column may not grow past the hero. An auto track takes the title's
min-content width, and a title that is one long word made the track, the tools
row and the boarding pass as wide as that word, with the tools off the card. */
grid-template-columns: minmax(0, 1fr);
}
.trek-dash .hero-top { display: flex; justify-content: space-between; align-items: start; }
.trek-dash .hero-badge {
@@ -221,14 +225,29 @@
}
.trek-dash .hero-tool:hover { background: oklch(0.22 0.01 260 / .78); transform: scale(1.05); }
.trek-dash .hero-tool svg { width: 17px; height: 17px; }
.trek-dash .hero-title-block { align-self: end; padding-bottom: 24px; }
.trek-dash .hero-title-block { align-self: end; padding-bottom: 24px; min-width: 0; }
.trek-dash .hero-eyebrow {
display: inline-flex; align-items: center; gap: 10px;
font-size: 11.5px; letter-spacing: 0.22em; text-transform: uppercase;
opacity: .88; margin-bottom: 16px; font-weight: 500;
}
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
.trek-dash .hero-title {
font-size: 104px; font-weight: 600; line-height: 1.12; letter-spacing: -0.045em; margin: 0;
text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4);
/* Two lines at most, a word longer than the card broken inside it: the rows above
and below have a fixed budget and a third line would run into the badge row. */
overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
/* The clamp clips at the line boxes and Poppins paints past them (ascender 1.05em,
descender 0.35em): at 0.9 every y, g and p on the last line lost its tail and
É and Å on the first their accent. The padding makes room for the ink and the
margin takes it back, so the title sits where it did. At 1.12 a clipped third
line keeps its plain ascenders under that padding; an accented capital on it
still leaves a sliver, as it does on dev today, and only a much looser title
would hide that. */
padding-block: .1em; margin-block: -.1em;
}
/* ----------------- boarding pass ----------------- */
/* The pass is split into an outer wrapper (left notch) and an inner glass panel
@@ -416,6 +435,11 @@
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
/* The row's cover is 100px and the title hangs from its bottom edge, so every
line it gains grows upwards: at 26px a second one starts above the status
badge at top 16px and runs through the pill. The row has 484px of text to
itself, one line is the whole title either way. */
.trek-dash .trips.list-view .trip-name { -webkit-line-clamp: 1; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
floating alone at the far left. */
@@ -470,7 +494,15 @@
.trek-dash .trip-action-btn:hover { background: oklch(0.22 0.01 260 / .82); transform: scale(1.06); }
.trek-dash .trip-action-btn svg { width: 17px; height: 17px; }
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
.trek-dash .trip-name {
font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.12; margin: 0;
text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38);
/* The cover is a fixed width and clipped, so the same clamp and ink room as
.hero-title: one long word used to run off the card edge as a single line. */
overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
padding-block: .1em; margin-block: -.1em;
}
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
.trek-dash .trip-body { padding: 18px 20px 20px; }
@@ -719,10 +751,9 @@
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
.trek-dash .trips.list-view .trip-name {
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* Taller banner (110px), smaller type and the title 11px off the bottom: two
lines end below the badge here, so the phone row keeps them. */
.trek-dash .trips.list-view .trip-name { font-size: 18px; -webkit-line-clamp: 2; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
+111
View File
@@ -0,0 +1,111 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, it, expect } from 'vitest'
// jsdom lays nothing out, so the rules themselves are the assertion, the way
// releaseNoticeCss.test.ts pins the notice's stacking.
describe('dashboard css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/styles/dashboard.css'), 'utf8')
const within = (source: string, selector: string): string => {
const at = source.indexOf(selector)
expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-1)
return source.slice(at, source.indexOf('}', at) + 1)
}
const block = (selector: string): string => within(css, selector)
const phone = (): string => {
const at = css.indexOf('@media (max-width: 720px)')
expect(at, 'the phone block').toBeGreaterThan(-1)
return css.slice(at, css.indexOf('\n}', at))
}
// The leading number of a declaration, undefined when the rule does not set it.
const number = (rule: string, prop: string): number | undefined => {
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*(-?[\\d.]+)`).exec(rule)
return found ? Number(found[1]) : undefined
}
const value = (rule: string, prop: string): number => {
const found = number(rule, prop)
expect(found, `${prop} missing from ${rule.slice(0, 48)}`).toBeDefined()
return found!
}
it('FE-DASH-CSS-001: a hero title that is one long word cannot widen the card', () => {
// Reported from Discord: with a title like "asdaaaa…" the tools row and the
// boarding pass grew to the width of the word, and edit, clone, archive and
// delete sat off the card. The grid column may not grow past the hero, and the
// title breaks inside the card and stops at two lines.
expect(block('.trek-dash .hero-content {')).toMatch(/grid-template-columns:\s*minmax\(0,\s*1fr\)/)
expect(block('.trek-dash .hero-title-block {')).toMatch(/min-width:\s*0/)
const title = block('.trek-dash .hero-title {')
expect(title).toMatch(/overflow-wrap:\s*anywhere/)
expect(title).toMatch(/-webkit-line-clamp:\s*2/)
expect(title).toMatch(/overflow:\s*hidden/)
})
it('FE-DASH-CSS-002: the clamp clips at the line boxes, so they leave room for the ink', () => {
// Poppins paints past its line box (ascender 1.05em, descender 0.35em). With
// the clamp's overflow: hidden at line-height 0.9, every y, g and p on the
// last line lost its tail and É and Å on the first their accent. The room is
// padding the margin takes back, and the line-height has to keep a clipped
// third line's plain ascenders below that padding, which 1.12 just does (an
// accented capital on that third line still leaves a sliver, as at 0.9).
for (const selector of ['.trek-dash .hero-title {', '.trek-dash .trip-name {']) {
const rule = block(selector)
expect(rule, selector).toMatch(/overflow:\s*hidden/)
expect(value(rule, 'line-height'), selector).toBeGreaterThanOrEqual(1.12)
const room = /padding-block:\s*0?\.(\d+)em/.exec(rule)?.[1]
expect(room, `${selector} pads no room for the ink`).toBeDefined()
expect(rule, selector).toMatch(new RegExp(`margin-block:\\s*-0?\\.${room}em`))
}
})
it('FE-DASH-CSS-003: a grid or list card breaks a one-word title inside the cover and stops at two lines', () => {
// The same title that stretched the hero was cut at the cover edge on every
// card: the cover content is absolutely positioned and the cover clips.
const name = block('.trek-dash .trip-name {')
expect(name).toMatch(/overflow-wrap:\s*anywhere/)
expect(name).toMatch(/-webkit-line-clamp:\s*2/)
})
it('FE-DASH-CSS-004: a list row uses every title line that fits above the status badge', () => {
// The list cover is a fixed band: the badge is pinned near its top and the
// title hangs from its bottom edge, so each line the title gains grows upwards
// into the badge. At 26px over a 100px cover the two-line clamp put the first
// line at y 23 and ran it through the pill; the phone banner is taller and its
// type smaller, so there two fit. jsdom lays nothing out, so the row is
// measured from the rules the browser would apply.
const inherited = 1.5 // html's line-height; nothing in dashboard.css resets it
const badge = block('.trek-dash .trip-status {')
const badgeBottom =
value(badge, 'top') + 2 * value(badge, 'border') + 2 * value(badge, 'padding') + value(badge, 'font-size') * inherited
const name = block('.trek-dash .trip-name {')
// What the row ends up with: its own override where it has one, the shared
// .trip-name rule otherwise.
const shell = (rule: string, prop: string): number => number(rule, prop) ?? value(name, prop)
const rows = [
{
label: 'desktop',
cover: block('.trek-dash .trips.list-view .trip-cover {'),
content: block('.trek-dash .trip-cover-content {'),
row: block('.trek-dash .trips.list-view .trip-name {')
},
{
label: 'phone',
cover: within(phone(), '.trek-dash .trips.list-view .trip-cover {'),
content: within(phone(), '.trek-dash .trips.list-view .trip-cover-content {'),
row: within(phone(), '.trek-dash .trips.list-view .trip-name {')
}
]
for (const { label, cover, content, row } of rows) {
const fontSize = shell(row, 'font-size')
const line = fontSize * shell(row, 'line-height')
// The clamp pads .1em for the ink and takes it back with a negative margin,
// so the box keeps its height but paints that much higher.
const ceiling = value(cover, 'height') - value(content, 'bottom') - fontSize * shell(row, 'padding-block')
const clamp = shell(row, '-webkit-line-clamp')
expect(ceiling - clamp * line, `${label} list title over the badge`).toBeGreaterThanOrEqual(badgeBottom)
expect(clamp, `${label} list title leaves a line unused`).toBe(Math.floor((ceiling - badgeBottom) / line))
}
})
})

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