Compare commits

..

42 Commits

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

Closes #2440
2026-09-22 09:56:13 +02:00
Maurice aa64572bf0 fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false; the nested objects authored inline in the tool shapes are strict
for the same reason. A whole Zod schema, the way a plugin's manifest-built
tool arrives, passes through as its author wrote it.
2026-09-22 09:55:45 +02:00
Maurice 23aec0d898 fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 09:55:33 +02:00
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
225 changed files with 5284 additions and 808 deletions
+15 -5
View File
@@ -27,7 +27,8 @@ COPY client/ ./client/
RUN npm run build --workspace=client RUN npm run build --workspace=client
# ── Stage 3: server ────────────────────────────────────────────────────────── # ── 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 FROM node:24-alpine AS server-builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ 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 # 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 # --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. # 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 && \ 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 && \ 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; \ 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 && \ 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 chown -R node:node /app
+14
View File
@@ -23,3 +23,17 @@ export const rtlTextAlias = {
'../dist/mapbox-gl-rtl-text.js', '../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',
),
};
+7 -8
View File
@@ -320,14 +320,13 @@ export default function App() {
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// A version is a short release tag and nothing else. It arrives over the // 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 // wire and is written to this device's storage, so only a value made of
// characters a tag may contain and kept only when nothing had to be // the characters a tag may contain is taken, as the match itself. Anything
// stripped. Anything else is ignored, which also keeps a malformed value // else is ignored, which also keeps a malformed value from being compared
// from being compared against the stored marker and starting an update on // against the stored marker and starting an update on every launch.
// every launch. const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
const reportedVersion = typeof config?.version === 'string' ? config.version : '' if (releaseTag) {
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64) const version = releaseTag[0]
if (version && version === reportedVersion) {
const storedVersion = localStorage.getItem('trek_app_version') const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker // 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 // after the purge and outside its try, so a throwing setItem (private
+9 -2
View File
@@ -671,6 +671,8 @@ export const adminApi = {
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data), updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data), getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).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), getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).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), getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
@@ -1215,13 +1217,18 @@ export const mapsApi = {
* than interleaved: the core list is ordered by relevance and has earned that order, * 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. * and a plugin's row carries its own `source` for a caller that wants to mark it.
*/ */
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) => /**
* `provider: 'google'` sends this one search to Google alone, the "search Google
* instead" link under a list the index answered with the wrong place. The
* server ignores it on an instance without a Google key.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => { 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 // 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 // the core call may reject: that rejection is what hands withCachedPlaces the
// offline path, and a plugin failure must never trigger it. // offline path, and a plugin failure must never trigger it.
const [core, extra] = await Promise.all([ 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), pluginSearchPlaces(query, lang, locationBias),
]) ])
if (extra.length === 0) return core if (extra.length === 0) return core
@@ -63,7 +63,8 @@ describe('CollabLinks', () => {
); );
render(<CollabLinks tripId={1} />); render(<CollabLinks tripId={1} />);
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument(); 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'); expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
}); });
@@ -118,6 +119,47 @@ describe('CollabLinks', () => {
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument(); 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-008: a viewer without edit rights gets no add button', async () => { 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. // collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } }); seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
+63 -32
View File
@@ -1,6 +1,6 @@
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react' import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' 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 { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -18,23 +18,34 @@ interface CollabLink {
pinned: boolean | number pinned: boolean | number
} }
function LinkIcon({ url, title }: { url: string; title: string }) { function LinkIcon({ url }: { url: string }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
let favicon = '' let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ } 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 (
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)' }} /> <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 * The add and edit form as a modal, like the poll and note panels next to it.
* pushed the list down and gave the panel a second header, which is what made * Inline it pushed the list down and gave the panel a second header, which is
* this tab read as a different app from its four siblings. * 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 { t } = useTranslation()
const [title, setTitle] = useState('') const [title, setTitle] = useState(link?.title ?? '')
const [url, setUrl] = useState('') const [url, setUrl] = useState(link?.url ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const valid = title.trim().length > 0 && url.trim().length > 0 const valid = title.trim().length > 0 && url.trim().length > 0
@@ -43,10 +54,10 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
if (!valid || busy) return if (!valid || busy) return
setBusy(true) setBusy(true)
try { try {
await onCreate({ title: title.trim(), url: url.trim() }) await onSave({ title: title.trim(), url: url.trim() })
onClose() onClose()
} catch { } 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 { } finally {
setBusy(false) setBusy(false)
} }
@@ -66,7 +77,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() }}> <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}> <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)' }}> <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> <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>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -99,6 +110,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const toast = useToast() const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([]) const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(null)
// Both are new objects on every render, so depending on them would reload the // 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. // list after each keystroke and drop a link that was just added back out of it.
@@ -168,6 +180,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) => { const remove = async (id: number) => {
try { try {
await collabApi.deleteLink(tripId, id) await collabApi.deleteLink(tripId, id)
@@ -197,36 +220,44 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
)} )}
</div> </div>
{/* Content */} {/* Content: one chip per link, two to a row (see .collab-link-chip). The chip is
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}> 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 ? ( {links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} /> <EmptyState scene="links" title={t('collab.links.empty')} />
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column' }}> <div className="collab-link-grid__cells">
{links.map(link => ( {links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}> <div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<LinkIcon url={link.url} title={link.title} /> <a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}> <LinkIcon url={link.url} />
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div> <span className="collab-link-chip__text">
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div> <span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
</span>
</a> </a>
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" /> {canEdit && (
{canEdit && <> <span className="collab-link-chip__actions">
<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' }}> <button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} title={t('collab.links.edit')}>
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /> <Pencil size={13} aria-hidden="true" />
</button> </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' }}> <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')}>
<Trash2 size={15} aria-hidden="true" /> <Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button> </button>
</>} <button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
)}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Create Modal */} {/* Create and edit modals */}
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />} {showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
</div> </div>
) )
} }
@@ -1028,4 +1028,21 @@ describe('EntryEditor', () => {
expect(onClose).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockRestore() 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()
})
}) })
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, useRef } from 'react' import { useEffect, useMemo, useState, useRef } from 'react'
import { localIsoDate } from '../../utils/localDate' 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 { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster' import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue' import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
@@ -11,7 +11,7 @@ import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants' 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 MarkdownToolbar from './MarkdownToolbar'
import { DatePicker } from './JourneyDetailPageDatePicker' import { DatePicker } from './JourneyDetailPageDatePicker'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
@@ -527,7 +527,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{photos.map((p, idx) => ( {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' : ''}`}> <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) ? (
// No poster to show and falling back to /original would hand
// an <img> the clip itself, so this tile stays a play badge.
<div className="w-full h-full bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
)}
{idx === 0 && photos.length > 1 && ( {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> <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 { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore' 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 { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger' import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
@@ -151,9 +151,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onClick={() => onPhotoClick(allPhotos, i)} onClick={() => onPhotoClick(allPhotos, i)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
> >
{photo.media_type === 'video' && {posterlessVideo(photo) ? (
photo.provider === 'local' &&
!photo.thumbnail_path ? (
// Poster-less local video: show a neutral tile. // Poster-less local video: show a neutral tile.
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" /> <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 { Image, Play } from 'lucide-react'
import type { JourneyPhoto } from '../../store/journeyStore' 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 }) { export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
const src = photoUrl(photo, 'thumbnail') 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 || ''}`} className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
style={style} style={style}
> >
<img {/* A clip without a poster keeps the black tile and the play badge; asking
src={src} for its thumbnail would only draw the broken-image glyph over them. */}
alt="" {!posterlessVideo(photo) && (
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`} <img
loading="lazy" src={src}
/> alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
)}
{isVideo && ( {isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
setView: vi.fn(), setView: vi.fn(),
flyTo: vi.fn(), flyTo: vi.fn(),
panTo: 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), getZoom: vi.fn(() => 10),
// Leaflet throws "Set map center and zoom first." out of these until the map // 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 // 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(); 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>(); const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />); 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(); act(() => { ref.current!.focusMarker('e1'); });
expect(mockedMap().flyTo).not.toHaveBeenCalled(); 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', () => { 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(); vi.useFakeTimers();
try { try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />); 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); }); act(() => { vi.advanceTimersByTime(60); });
// The catch is where a map with no view at all gets its first one. expect(mockedMap().panTo).not.toHaveBeenCalled();
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12); 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(); expect(mockedMap().flyTo).not.toHaveBeenCalled();
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
+23 -13
View File
@@ -161,6 +161,27 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
</div>` </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_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = [] const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */ /** 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) => { const focusMarker = useCallback((id: string) => {
highlightMarker(id) highlightMarker(id)
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
if (marker && mapRef.current) { if (marker && mapRef.current) panToMarker(mapRef.current, marker)
try {
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
} catch { /* map not yet initialized */ }
}
}, []) }, [])
const invalidateSize = useCallback(() => { const invalidateSize = useCallback(() => {
@@ -511,14 +528,7 @@ function JourneyMap(
highlightMarker(activeMarkerId) highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId) const marker = markersRef.current.get(activeMarkerId)
if (!marker || !mapRef.current) return if (!marker || !mapRef.current) return
// Pan, don't zoom — see focusMarker. fitBounds may still be pending when this panToMarker(mapRef.current, marker)
// 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)
}
}, 50) }, 50)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [activeMarkerId]) }, [activeMarkerId])
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const plyrCtor = vi.fn()
vi.mock('plyr', () => ({
default: class {
constructor(el: HTMLElement, options: Record<string, unknown>) {
plyrCtor(el, options)
}
destroy = vi.fn()
},
}))
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 unmounted in the same tick.
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')
})
})
@@ -1,6 +1,11 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import Plyr from 'plyr' import Plyr from 'plyr'
import 'plyr/dist/plyr.css' 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'
/** /**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native * Video player for gallery/lightbox playback (#823), built on Plyr over a native
@@ -40,6 +45,12 @@ export default function VideoPlayer({
autoplay: autoPlay, autoplay: autoPlay,
clickToPlay: true, clickToPlay: true,
hideControls: false, 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 unmounted in the same
// tick, so the error it would otherwise report has nowhere to show.
blankVideo: '',
}) })
return () => { return () => {
@@ -102,6 +102,46 @@ describe('buildDayRouteRuns', () => {
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false) 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', () => { 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([]) 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[][] { export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input 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 => { const dayOrder = (id: number | null | undefined): number | null => {
if (id == null) return null if (id == null) return null
const d = allDays.find(x => x.id === id) const d = allDays.find(x => x.id === id)
+18
View File
@@ -253,6 +253,24 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO') 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 } 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)
})
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => { 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' } const sameDay = { ...transportReservation, reservation_end_time: '2025-06-01T16:45:00' }
await downloadTripPDF({ ...richArgs, reservations: [sameDay] }) await downloadTripPDF({ ...richArgs, reservations: [sameDay] })
+7 -1
View File
@@ -335,7 +335,13 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
} }
// Build day HTML // Build day HTML
const daysHtml = sorted.map((day, di) => { const daysHtml = sorted.map((day, di) => {
const assigned = (assignments[String(day.id)] || []).slice() // Without the stop a booked night wrote onto its check-in day: the day plan hides
// it (the day already carries the booking as its accommodation block) and the
// export lists what the plan lists. The desktop toolbar hands the export the
// store's own assignments, filter and all, and the stop sits at the head of its
// day, so the hotel printed above a morning flight (#2434).
const assigned = (assignments[String(day.id)] || [])
.filter((a: any) => a.accommodation_id == null)
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0)) .sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice() const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) .sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
@@ -223,6 +223,17 @@ describe('DayPlanSidebar', () => {
expect(document.body).toBeInTheDocument() 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', () => { it('FE-PLANNER-DAYPLAN-002: renders day titles', () => {
const day = buildDay({ title: 'Amsterdam Day', date: '2025-06-01' }) const day = buildDay({ title: 'Amsterdam Day', date: '2025-06-01' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
@@ -1525,7 +1525,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} }
return ( 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 */} {/* Toolbar */}
<DayPlanSidebarToolbar <DayPlanSidebarToolbar
tripId={tripId} tripId={tripId}
@@ -186,6 +186,53 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument(); 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-019: pressing Enter in search input triggers search', async () => { it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
@@ -13,12 +13,12 @@ import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker' import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn' import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast' 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 { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers' import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError' import { getApiErrorMessage } from '../../utils/apiError'
import { sourceLabelFor } from '../../utils/placeSource' import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias' import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection' import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -396,7 +396,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setForm(prev => ({ ...prev, [field]: value })) setForm(prev => ({ ...prev, [field]: value }))
} }
const handleMapsSearch = async () => { const handleMapsSearch = async (provider?: 'google') => {
if (!mapsSearch.trim()) return if (!mapsSearch.trim()) return
setIsSearchingMaps(true) setIsSearchingMaps(true)
try { try {
@@ -419,7 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return return
} }
} }
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint) const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' } searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || []) setMapsResults(result.places || [])
setSearchSource(result.source || '') setSearchSource(result.source || '')
@@ -1002,6 +1002,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
))} ))}
</div> </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
that has a key and for a list Google did not already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
<button
type="button"
onClick={() => handleMapsSearch('google')}
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> </div>
{/* Name */} {/* Name */}
@@ -1,5 +1,5 @@
// FE-COMP-PLACES-001 to FE-COMP-PLACES-015 + FE-PLANNER-SIDEBAR-016 to 043 // 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 userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
@@ -50,6 +50,12 @@ beforeEach(() => {
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); 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', () => { describe('PlacesSidebar', () => {
it('FE-COMP-PLACES-001: renders without crashing', () => { it('FE-COMP-PLACES-001: renders without crashing', () => {
render(<PlacesSidebar {...defaultProps} />); render(<PlacesSidebar {...defaultProps} />);
@@ -251,7 +257,7 @@ describe('Filter tabs', () => {
const unplanned = buildPlace({ name: 'Unplanned Place' }); const unplanned = buildPlace({ name: 'Unplanned Place' });
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />); 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.queryByText('Planned Place')).not.toBeInTheDocument();
expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
}); });
@@ -262,8 +268,8 @@ describe('Filter tabs', () => {
const unplanned = buildPlace({ name: 'Unplanned Place' }); const unplanned = buildPlace({ name: 'Unplanned Place' });
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />); render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i })); await pickFilter(user, 'Unplanned');
await user.click(screen.getByRole('button', { name: /^All/i })); await pickFilter(user, 'All');
expect(screen.getByText('Planned Place')).toBeInTheDocument(); expect(screen.getByText('Planned Place')).toBeInTheDocument();
expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
}); });
@@ -273,7 +279,7 @@ describe('Filter tabs', () => {
const place = buildPlace({ name: 'Assigned Place' }); const place = buildPlace({ name: 'Assigned Place' });
const assignments = { '1': [buildAssignment({ place, day_id: 1 })] }; const assignments = { '1': [buildAssignment({ place, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[place]} assignments={assignments} />); 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(); expect(screen.getByText(/All places are planned/i)).toBeInTheDocument();
}); });
@@ -515,7 +521,7 @@ describe('Category filter dropdown', () => {
const unplanned = buildPlace({ name: 'Unplanned Place' }); const unplanned = buildPlace({ name: 'Unplanned Place' });
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
const { unmount } = render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />); 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(); expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
unmount(); unmount();
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />); 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 { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
import CustomSelect from '../shared/CustomSelect'
import { useElementSize } from '../../hooks/useElementSize' import { useElementSize } from '../../hooks/useElementSize'
import type { SidebarState } from './usePlacesSidebar' import type { SidebarState } from './usePlacesSidebar'
@@ -177,40 +178,17 @@ export function PlacesHeader(S: SidebarState) {
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }> ] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }>
return ( return (
// The group always spans the rail and never wraps onto a second line. // One select where four pills used to fight for the rail's width: with the
// Each tab keeps its own text width and only the leftover space is // tracks tab the row had grown past what a narrow panel can hold without
// shared out, so "Unplanned" stays wider than "All" instead of the four // clipping a word. The count rides on the trigger the way it rode on the
// being forced to one size. // active pill, and every option carries its own in the menu.
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'nowrap' }}> <div data-testid="places-filter" style={{ marginBottom: 8 }}>
{tabs.map(f => { <CustomSelect
const active = filter === f.id size="sm"
return ( value={filter}
<button type="button" onChange={v => { setFilter(v as 'all' | 'unplanned' | 'planned' | 'tracks'); setSelectedIds(new Set()) }}
key={f.id} options={tabs.map(f => ({ value: f.id, label: f.label, badge: String(counts[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>
)
})}
</div> </div>
) )
})()} })()}
@@ -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) { 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" // 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) let hour = Number.parseInt(h)
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12 if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0 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 { DryPoint } from './roadtripModel'
import type { RefuelSearch } from './useRefuelSearch' import type { RefuelSearch } from './useRefuelSearch'
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion' 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>) const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
@@ -1087,3 +1087,164 @@ describe('RoadtripModeSwitch', () => {
expect(onChange).toHaveBeenCalledWith(false) 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)
})
})
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
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
const rental = 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: [],
},
})
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
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-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} />)
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()
})
})
@@ -1,6 +1,6 @@
import ChargingInfo from './ChargingInfo' import ChargingInfo from './ChargingInfo'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useState } from 'react' import React, { useMemo, useState } from 'react'
import { import {
CarFront, Footprints, Bike, Zap, AlertTriangle, CarFront, Footprints, Bike, Zap, AlertTriangle,
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
@@ -17,7 +17,7 @@ import { formatDistance } from '../../utils/units'
import { formatDate, formatClockTime } from '../../utils/formatters' import { formatDate, formatClockTime } from '../../utils/formatters'
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel' import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds' import { STOP_KIND_BY_KEY } from './stopKinds'
import { legReroutable } from './roadtripRowModel' import { destinationCount, isHop, legReroutable } from './roadtripRowModel'
import { spurWorthLabelling } from './accessSpur' import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker' import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker' import StopFillPicker from './StopFillPicker'
@@ -31,6 +31,9 @@ import { FS } from './typeScale'
import type { RouteSegment } from '../../types' import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop' import AutomaticDayStop from './AutomaticDayStop'
import { carrierIcon, rideText, terminalLine } from './carrierRide'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal' import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading' import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
@@ -102,6 +105,16 @@ interface RoadtripSidebarProps {
collapsedDayIds?: Set<number> collapsedDayIds?: Set<number>
/** Absent leaves every card open and its header unclickable. */ /** Absent leaves every card open and its header unclickable. */
onToggleDay?: (dayId: number) => void 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
} }
const MODE_ICON: Record<string, LucideIcon> = { const MODE_ICON: Record<string, LucideIcon> = {
@@ -680,6 +693,18 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const mode = leg?.mode ?? 'driving' const mode = leg?.mode ?? 'driving'
const Icon = mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront const Icon = 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 (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 // The band's contents, shared by the clickable and the read-only shape so the two can
// never drift apart in what they say. // never drift apart in what they say.
const band = ( const band = (
@@ -748,6 +773,209 @@ 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, selected, continues, starts, onOpen }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
selected: boolean
continues: boolean
starts?: boolean
onOpen?: () => void
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const carrier = stop.carrier!
const Icon = carrierIcon(carrier.type)
const line = terminalLine(carrier, t, is12h)
return (
<button
type="button"
onClick={onOpen}
disabled={!onOpen}
aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
aria-current={selected ? 'true' : 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={`flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors ${
selected ? 'bg-surface-selected' : 'group-hover:bg-surface-hover'
}`}
>
<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>
{carrier.code ? (
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{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>
</button>
)
}
/**
* One end of a ride inside the ride's block: the terminal's name and code, the
* timetable's clock under it, and on the right the time the chain has the traveller
* there. The same three things the lone terminal row shows, without a row of its own.
*/
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">
<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, onOpen }: {
bookings: Reservation[]
continues: 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 ? (
<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. * A charger, a filling station or a rest stop — a stop that interrupts the drive.
* *
@@ -1289,7 +1517,7 @@ function SpillBlock({ spill, children }: {
* move, a stay edit or a refuel offer still names the day the server knows it by. See * move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`. * `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, reservations, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint'] onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay day: RoadtripDay
/** Folded down to the header, and off the map with it. */ /** Folded down to the header, and off the map with it. */
@@ -1303,6 +1531,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
drag: DragState drag: DragState
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives'] onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
openAlternatives?: RoadtripSidebarProps['openAlternatives'] openAlternatives?: RoadtripSidebarProps['openAlternatives']
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
reservations?: Reservation[]
onEditStay?: RoadtripSidebarProps['onEditStay'] onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind'] onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill'] onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
@@ -1319,6 +1549,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
// Which disc the picker hangs under, and for which stop. One at a time: two open // 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. // popovers over the same rail is two answers to one question.
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const bookings = useMemo(() => dayBookings(day, reservations ?? []), [day, reservations])
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t, language } = useTranslation() const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -1341,7 +1572,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is * `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`). * 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'))
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
if (stop.automaticNight) return ( if (stop.automaticNight) return (
<li key={stop.assignmentId}> <li key={stop.assignmentId}>
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} /> <AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
@@ -1356,12 +1589,60 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
)) : null} )) : null}
</li> </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 // 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 // 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. // 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}
selected={false}
continues={i < last}
starts={i === 0}
onOpen={open}
/>
)}
{tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? (
<DriveBand leg={day.legs[tail]} />
) : null}
{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 ownDay = stop.ownerDayId
const ownIndex = stop.ownerIndex const ownIndex = stop.ownerIndex
return ( return (
@@ -1426,6 +1707,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/> />
)} )}
{bookings.atStop.has(i) ? (
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} onOpen={onOpenBooking} />
) : null}
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? ( {i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand <DriveBand
leg={day.legs[i]} leg={day.legs[i]}
@@ -1553,12 +1837,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
{formatDate(day.date, language)} {formatDate(day.date, language)}
</time> </time>
) : null} ) : null}
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}> {/* A day that is only the night booked for it has no drive to sum up and no
{t('roadtrip.leg.driveText', { stop to count; the hotel row below says everything it has to say. */}
distance: formatDistance(day.distance / 1000, distanceUnit), {day.legs.length > 0 ? (
time: formatDurationShort(day.duration), <span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
})} {t('roadtrip.leg.driveText', {
</span> distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})}
</span>
) : null}
{day.dayWarning ? ( {day.dayWarning ? (
<Tooltip label={t('roadtrip.limit.hint')}> <Tooltip label={t('roadtrip.limit.hint')}>
<span className={`${DAY_BADGE} gap-1 bg-warning-soft text-warning`} style={{ fontSize: FS.label }}> <span className={`${DAY_BADGE} gap-1 bg-warning-soft text-warning`} style={{ fontSize: FS.label }}>
@@ -1569,9 +1857,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
</span> </span>
</Tooltip> </Tooltip>
) : null} ) : null}
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}> {day.legs.length > 0 ? (
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })} <span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
</span> {t('roadtrip.day.stopCount', { count: destinationCount(day) })}
</span>
) : null}
{/* The other half of "where possible". The setting is a weighting, so a day {/* 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 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 worse than not avoiding it is not avoiding it silently, which reads as the
@@ -1631,6 +1921,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
</React.Fragment> </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} onOpen={onOpenBooking} />
</li>
) : null}
</ol> </ol>
{picking ? ( {picking ? (
<StopKindPicker <StopKindPicker
@@ -1731,7 +2033,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
* them is a pill just for being a number. * them is a pill just for being a number.
*/ */
export default function RoadtripSidebar({ export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay, routes, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel, onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay, onFocusPoint, collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement { }: RoadtripSidebarProps): React.ReactElement {
@@ -1789,6 +2091,8 @@ export default function RoadtripSidebar({
drag={drag} drag={drag}
onAskAlternatives={onAskAlternatives} onAskAlternatives={onAskAlternatives}
openAlternatives={openAlternatives} openAlternatives={openAlternatives}
onOpenBooking={onOpenBooking}
reservations={reservations}
onEditStay={onEditStay} onEditStay={onEditStay}
onSetStopKind={onSetStopKind} onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill} onSetStopFill={onSetStopFill}
@@ -0,0 +1,47 @@
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
import { formatDurationShort } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters'
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../types'
/**
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
* the phone chain (#2428).
*
* 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.
*/
export function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string {
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
return duration ? `${carrier.title} · ${duration}` : carrier.title
}
@@ -520,3 +520,88 @@ describe('legReroutable', () => {
expect(legReroutable(extra, -1)).toBe(false) 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')
})
})
@@ -1,5 +1,5 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel' 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' import { readStay } from './stayReading'
/** /**
@@ -21,13 +21,19 @@ export type RoadtripRow =
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] } | { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
| StopRow | StopRow
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null } | { 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: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null } | { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
export interface StopRow { export interface StopRow {
kind: 'stop' kind: 'stop'
stop: RoadtripStop 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 number: number | null
service: boolean service: boolean
entry: ScheduleEntry | undefined entry: ScheduleEntry | undefined
@@ -132,6 +138,20 @@ function isStationary(seg: RouteSegment | undefined): boolean {
&& seg.from[1] === seg.to[1] && 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[] { export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
const rows: RoadtripRow[] = [] const rows: RoadtripRow[] = []
const spills = day.spills ?? [] 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 const automatic = !!stop.automaticNight
if (automatic) { if (automatic) {
rows.push({ rows.push({
@@ -155,22 +182,44 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end', phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
time: day.schedule.entries[i]?.arrival ?? null, 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 { } else {
if (!isServiceStopType(stop.stopType)) number += 1 // A terminal or a hire car's desk is where the drive stops or resumes, not a
rows.push(stopRow(stop, i, isServiceStopType(stop.stopType) ? null : number, day.schedule, day.driveWarnings)) // 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 pushLeg(i)
// between two stops, so they are emitted here rather than in their own pass. })
// 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] 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 }) 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) const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
if (dry) { if (dry) {
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm }) rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
} }
} }
}) }
return rows return rows
} }
@@ -191,13 +240,19 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
return index >= 0 return index >= 0
&& index < day.stops.length - 1 && index < day.stops.length - 1
&& !!day.legs[index] && !!day.legs[index]
&& !isHop(day.legs[index])
&& !day.stops[index].automaticNight && !day.stops[index].automaticNight
&& !day.stops[index + 1].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. */ /** Stops that carry a number, for a count that agrees with the numbering above. */
export function destinationCount(day: RoadtripDay): number { 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
} }
/** /**
@@ -276,7 +331,7 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
*/ */
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null { export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null {
for (const day of days) { 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 if (stop) return stop
} }
return null return null
@@ -0,0 +1,65 @@
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-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,82 @@
import { Ticket, type LucideIcon } from 'lucide-react'
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
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
}
export function dayBookings(
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
reservations: readonly Reservation[],
): 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 = r.day_id === day.dayId || (r.end_day_id != null && r.end_day_id === day.dayId)
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
if (index >= 0) {
const list = atStop.get(index) ?? []
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,26 @@ describe('useCorridorPois', () => {
expect(result.current.failedAreas).toBeGreaterThan(0) expect(result.current.failedAreas).toBeGreaterThan(0)
expect(pois.mock.calls.length).toBeLessThan(result.current.progress.total + 1) 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)
})
}) })
@@ -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 * 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. * 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.
*/
gaps?: { from: LatLng; to: LatLng }[]
},
): CorridorSearch { ): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
const gaps = options?.gaps
const gapKey = (gaps ?? []).map(g => `${g.from.lat.toFixed(5)},${g.from.lng.toFixed(5)}>${g.to.lat.toFixed(5)},${g.to.lng.toFixed(5)}`).join(';')
const { locale } = useTranslation() const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([]) const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 }) const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -139,7 +152,31 @@ export function useCorridorPois(
// spine would renumber both the moment the window moved, and a hit would land at the // spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain. // wrong position in the day's chain.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
const allTiles = corridorTiles(tileLine, widthKm) // The ridden stretches, as kilometres along the spine, in order.
const skipped = (gaps ?? [])
.map(gap => {
const a = projectOntoRoute(gap.from, spine)?.alongKm
const b = projectOntoRoute(gap.to, spine)?.alongKm
return a === undefined || b === undefined ? null : { fromKm: Math.min(a, b), toKm: Math.max(a, b) }
})
.filter((g): g is { fromKm: number; toKm: number } => g !== null && g.toKm > g.fromKm)
.sort((a, b) => a.fromKm - b.fromKm)
const inGap = (alongKm: number): boolean => skipped.some(g => alongKm > g.fromKm + 0.05 && alongKm < g.toKm - 0.05)
// The tile line cut around the gaps: one piece per driven stretch.
const pieces: LatLng[][] = []
if (skipped.length) {
const start = window?.fromKm ?? 0
const end = window?.toKm ?? Number.POSITIVE_INFINITY
let cursor = start
for (const gap of skipped) {
if (gap.fromKm > cursor) pieces.push(sliceAtMeters(spine, cursor * 1000, Math.min(gap.fromKm, end) * 1000))
cursor = Math.max(cursor, gap.toKm)
}
if (cursor < end) pieces.push(Number.isFinite(end) ? sliceAtMeters(spine, cursor * 1000, end * 1000) : sliceAtMeters(spine, cursor * 1000, Number.MAX_SAFE_INTEGER))
} else {
pieces.push(tileLine)
}
const allTiles = pieces.filter(piece => piece.length > 1).flatMap(piece => corridorTiles(piece, widthKm))
const tiles = allTiles.slice(0, budget.maxTiles) const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now() const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
@@ -184,7 +221,7 @@ export function useCorridorPois(
for (const poi of data.pois) { for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine) const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm) continue if (!hit || hit.offRouteKm > widthKm || inGap(hit.alongKm)) continue
seen.set(poi.osm_id, { seen.set(poi.osm_id, {
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null, ...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null, opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
@@ -251,7 +288,10 @@ export function useCorridorPois(
setError(failures === jobs.length && failures > 0) setError(failures === jobs.length && failures > 0)
setLoading(false) setLoading(false)
})() })()
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs]) // gapKey stands in for `gaps`: the same terminals mean the same gaps, and a fresh
// array every render would make every search a new function.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gapKey])
useEffect(() => () => abortRef.current?.abort(), []) useEffect(() => () => abortRef.current?.abort(), [])
@@ -119,7 +119,21 @@ export function useRoadtripCorridor(
return day.stops.map(s => ({ lat: s.lat, lng: s.lng })) return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day]) }, [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 gaps = useMemo(() => {
if (!day) return []
const out: { from: LatLng; to: LatLng }[] = []
day.stops.forEach((stop, i) => {
const next = day.stops[i + 1]
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
out.push({ from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } })
}
})
return out
}, [day])
const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps }), [options, gaps]))
/** /**
* Whether what the panel looks for has been decided yet. * Whether what the panel looks for has been decided yet.
@@ -849,3 +849,122 @@ describe('a visit End on the road trip', () => {
vi.mock('../../hooks/useRoadtripSettings', () => ({ vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)), 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-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 { 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, seatCarrierStops, standsAsDay, 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 { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode' import { resolveLegMode } from '../Planner/legMode'
@@ -7,7 +7,7 @@ import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel'
import { spillChains } from './nightSpill' import { spillChains } from './nightSpill'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useVehicleRange } from './useVehicleRange' 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 type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
import { dayWindow } from './dayWindow' import { dayWindow } from './dayWindow'
import { useTranslation } from '../../i18n/TranslationContext' import { useTranslation } from '../../i18n/TranslationContext'
@@ -60,9 +60,7 @@ const stopKey = (s: RoadtripStop): string =>
* so the two cannot drift and quietly stop refetching. * so the two cannot drift and quietly stop refetching.
*/ */
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string => const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string =>
(viasByDay[from.ownerDayId] ?? []) viasLeaving(from, viasByDay[from.ownerDayId] ?? [])
.filter(v => v.after_order_index === from.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`) .map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
.join('|') .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 legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
const EMPTY_ACCOMMODATIONS: Accommodation[] = [] const EMPTY_ACCOMMODATIONS: Accommodation[] = []
const EMPTY_RESERVATIONS: Reservation[] = []
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => { const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
const p = a.place 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. // traveller's own statement about this stop, and the stay then runs until it.
leaveAt: a.assignment_end_time ?? p.end_time ?? null, leaveAt: a.assignment_end_time ?? p.end_time ?? null,
checkInTime: stay?.check_in ?? null, checkInTime: stay?.check_in ?? null,
night: stay !== undefined,
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null, dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
endDay: a.end_day === true, endDay: a.end_day === true,
legMode: a.leg_transport_mode ?? null, legMode: a.leg_transport_mode ?? null,
@@ -126,6 +126,13 @@ export function useRoadtripRoutes(
viasByDay: Record<number, RoadtripVia[]> = {}, viasByDay: Record<number, RoadtripVia[]> = {},
boundaries: RoadtripDayBoundary[] = [], boundaries: RoadtripDayBoundary[] = [],
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS, 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 { ): RoadtripRoutes {
const { t } = useTranslation() const { t } = useTranslation()
const routeProfile = fallbackProfile || 'driving' const routeProfile = fallbackProfile || 'driving'
@@ -201,47 +208,45 @@ export function useRoadtripRoutes(
[legMinutes, dayMinutes, planningRangeKm, fillPercent], [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] return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(d => { .map(d => {
const stops = (assignments[String(d.id)] ?? []) const located = (assignments[String(d.id)] ?? [])
.slice() .slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(a => asStop(a, d.id, 0, accommodations)) .map(a => ({ assignment: a, stop: asStop(a, d.id, 0, accommodations) }))
.filter((s): s is RoadtripStop => s !== null) .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 // 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 // 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. // counting before the filter would name a row the rail does not draw.
.map((s, i) => ({ ...s, ownerIndex: i })) const stops = located.map((x, i) => ({ ...x.stop, ownerIndex: i }))
return { return {
dayId: d.id, dayId: d.id,
dayNumber: d.day_number ?? 0, dayNumber: d.day_number ?? 0,
date: d.date ?? null, date: d.date ?? null,
title: d.title ?? 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, rideSeams])
}, [days, assignments, accommodations])
const quietDays = useMemo<QuietDay[]>(() => { const plan = useMemo<PlanDay[]>(() => storedDays.filter(d => standsAsDay(d.stops)), [storedDays])
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) const quietDays = useMemo<QuietDay[]>(() => storedDays.filter(d => !standsAsDay(d.stops)), [storedDays])
.map(d => ({
dayId: d.id, /** Days apart, for a ride that lands on a later day than it left. */
dayNumber: d.day_number ?? 0, const dayNumberOf = (dayId: number): number => days.find(d => d.id === dayId)?.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])
// Only the geometry decides whether legs have to be re-fetched: renaming a place or // Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round. // editing its notes must not fire a routing round.
@@ -300,6 +305,15 @@ export function useRoadtripRoutes(
* into a burst at the one host that refused it. * into a burst at the one host that refused it.
*/ */
const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => { const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => {
// A ride is no road: the leg between its terminals is the booking's minutes, and
// no router is asked. Still a task of its own, so it is published with the rest
// and a day that is nothing but a ride is not left waiting for a round that never
// comes.
const rides = carrierLegsFor(run, mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
if (rides) {
tasks.push(async () => { Object.assign(dayLegs, rides) })
return
}
/** The same stops, as one request per pair. A pair has nothing left to split. */ /** The same stops, as one request per pair. A pair has nothing left to split. */
const splitIntoPairs = (): void => { const splitIntoPairs = (): void => {
if (run.length <= 2) return if (run.length <= 2) return
@@ -322,9 +336,7 @@ export function useRoadtripRoutes(
// index — this used to be the position within the chain, so on any day that // 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 // received a night drive every via matched nothing and quietly stopped
// shaping the road. // shaping the road.
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? []) viasLeaving(stop, viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
.sort((a, b) => a.sequence - b.sequence)
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng })) .forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
}) })
@@ -527,14 +539,20 @@ export function useRoadtripRoutes(
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode }, { isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile, days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
) )
// A ride that lands on a later day is the one seam that is never a road: its
// terminals are on two cards, and the leg between them is the booking's minutes.
const ride = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
if (ride) {
const key = legKey(seam.from, seam.to)
setSeamLegs(prev => ({ ...prev, [key]: { ...ride[key], shape: seamShape(seam.from, viasByDay) } }))
continue
}
try { try {
// The via points on this seam, threaded in the same way the day runs thread // 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 // 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 // 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. // never reached the router and dragging one did visibly nothing.
const shaping = (viasByDay[seam.from.ownerDayId] ?? []) const shaping = viasLeaving(seam.from, viasByDay[seam.from.ownerDayId] ?? [])
.filter(v => v.after_order_index === seam.from.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
const r = await calculateRouteWithLegs( const r = await calculateRouteWithLegs(
[ [
{ lat: seam.from.lat, lng: seam.from.lng }, { lat: seam.from.lat, lng: seam.from.lng },
@@ -67,9 +67,20 @@
line-height: normal; line-height: normal;
} }
/* Below the two-column breakpoint the halves stack and the panel scrolls. */ /* 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) { @media (max-width: 1080px) {
.rn-panel { grid-template-columns: 1fr; overflow-y: auto; } .rn-panel { grid-template-columns: 1fr; grid-template-rows: auto; overflow-y: auto; }
.rn-release, .rn-release-inner, .rn-note-body { overflow: visible; }
/* 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, [data-reduce-motion='true'] .rn-overlay,
@@ -581,6 +592,15 @@
.rn-note-body::-webkit-scrollbar { display: none; } .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 { .rn-note-eyebrow {
font-family: var(--font-subtext); font-family: var(--font-subtext);
font-size: 11px; font-size: 11px;
@@ -36,4 +36,15 @@ describe('release notice css', () => {
expect(step('--z-notice')).toBeGreaterThan(step('--z-nav')) expect(step('--z-notice')).toBeGreaterThan(step('--z-nav'))
expect(step('--z-notice')).toBeGreaterThan(61) 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/)
})
}) })
+130
View File
@@ -1375,6 +1375,136 @@ img[alt="TREK"] {
justify-content: center; 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); }
@media (hover: none) {
.collab-link-chip__actions { opacity: 1; }
}
/* Markdown in Collab Notes */ /* Markdown in Collab Notes */
.collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; } .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; } .collab-note-md em, .collab-note-md-full em { font-style: italic !important; }
@@ -34,6 +34,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
placeShadowEnabled, setPlaceShadowEnabledState, placeShadowEnabled, setPlaceShadowEnabledState,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc, oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
@@ -350,6 +351,17 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/> />
} }
/> />
<MAdminRow
title={t('admin.placesGoogleOnly.title')}
hint={t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}
trailing={
<MToggle
checked={placesGoogleOnly}
ariaLabel={t('admin.placesGoogleOnly.title')}
onChange={() => handleTogglePlacesGoogleOnly()}
/>
}
/>
</MBlockDisclosure> </MBlockDisclosure>
</MProviderBlock> </MProviderBlock>
@@ -3,14 +3,16 @@ import type { CSSProperties, ReactNode } from 'react'
import MDancingTrek from '../../../components/MDancingTrek' import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn' import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel' import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units' import { formatDistance } from '../../../../utils/units'
import { formatClockTime } from '../../../../utils/formatters' 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 type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion' import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip' 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. * 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 }) { function Disc({ row, t, onPickKind }: { row: StopRow; t: TranslationFn; onPickKind?: () => void }) {
const kind = row.stop.stopType ? STOP_KIND_BY_KEY[row.stop.stopType] : undefined 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 const face = row.service && kind
? ( ? (
<span <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>) 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) { if (!marks.length && row.offRoadMeters) {
marks.push( marks.push(
<Mark key="off" icon={<Footprints size={10} strokeWidth={2} />}> <Mark key="off" icon={<Footprints size={10} strokeWidth={2} />}>
@@ -173,6 +187,105 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
cycling: Bike, 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. Each opens its
* booking; without `onOpen` they are plain.
*/
export function RtBookingChips({ bookings, chrome, onOpen }: {
bookings: Reservation[]
chrome: RowChrome
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 ? (
<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. * 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, useMemo, useRef } from 'react'
import { AlertTriangle, MapPin, Navigation } from 'lucide-react' import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe' import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip' import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
import { useMRtAlternatives } from './useMRtAlternatives' import { useMRtAlternatives } from './useMRtAlternatives'
import MRtCorridorBar from './MRtCorridorBar' import MRtCorridorBar from './MRtCorridorBar'
import MRtAlternativesBar from './MRtAlternativesBar' 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 MBadge from '../../../components/MBadge'
import MDancingTrek from '../../../components/MDancingTrek' import MDancingTrek from '../../../components/MDancingTrek'
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
@@ -17,6 +17,8 @@ import { formatClockTime } from '../../../../utils/formatters'
import { isRtlLanguage } from '../../../../i18n' import { isRtlLanguage } from '../../../../i18n'
import type { MTripTabPanelProps } from '../MTripShell' import type { MTripTabPanelProps } from '../MTripShell'
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel' import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
import type { Reservation } from '../../../../types'
/** /**
* The road trip tab: one day of the drive, as a chain or on the map. * The road trip tab: one day of the drive, as a chain or on the map.
@@ -77,7 +79,29 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
const canEditPlaces = planner.can('place_edit', planner.trip) const canEditPlaces = planner.can('place_edit', planner.trip)
const openStop = (row: StopRow) => { 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 })
}
// 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, and only for somebody allowed to.
const bookings = useMemo(() => {
const byIndex = stage ? dayBookings(stage, planner.reservations) : 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])
const openBooking = (res: Reservation) => {
if (planner.TRANSPORT_TYPES.has(res.type)) {
shell.openSheet('transport', { reservationId: res.id })
return
}
if (!planner.can('reservation_edit', planner.trip)) return
planner.setEditingReservation(res)
planner.setShowReservationModal(true)
} }
// The search bar sits in the same band on both halves, at the same offset, so the // The search bar sits in the same band on both halves, at the same offset, so the
@@ -224,20 +248,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"> <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) => { {rt.rows.map((row, i) => {
if (row.kind === 'stop') { if (row.kind === 'stop') {
const chips = bookings.atStop.get(row.stop.assignmentId)
return ( return (
<RtStopRow <Fragment key={`s${i}`}>
key={`s${i}`} <RtStopRow
row={row} row={row}
chrome={chrome} chrome={chrome}
onOpen={() => openStop(row)} onOpen={() => openStop(row)}
onPickKind={canEditPlaces onPickKind={canEditPlaces && !row.stop.carrier
? () => shell.openSheet('rtkind', { ? () => shell.openSheet('rtkind', {
placeId: row.stop.placeId, placeId: row.stop.placeId,
stopType: row.stop.stopType ?? null, stopType: row.stop.stopType ?? null,
name: row.stop.name, name: row.stop.name,
}) })
: undefined} : undefined}
/> />
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />}
</Fragment>
) )
} }
if (row.kind === 'leg') { if (row.kind === 'leg') {
@@ -256,6 +283,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 === 'auto') return <RtAutoRow key={`a${i}`} phase={row.phase} time={row.time} chrome={chrome} />
if (row.kind === 'spill') { if (row.kind === 'spill') {
return <RtSpillRow key={`p${i}`} fromDayNumber={row.fromDayNumber} departs={row.departs} chrome={chrome} /> return <RtSpillRow key={`p${i}`} fromDayNumber={row.fromDayNumber} departs={row.departs} chrome={chrome} />
@@ -279,6 +309,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} onOpen={openBooking} />
</div>
)}
</section> </section>
</> </>
)} )}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react' 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 { mapsApi } from '../../../../api/client'
import { sourceLabelFor } from '../../../../utils/placeSource' import { useAuthStore } from '../../../../store/authStore'
import { offersGoogleRetry, sourceLabelFor } from '../../../../utils/placeSource'
import { recordPlacePick } from '../../../../api/placeShadow' import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession' import { PlacesSession } from '../../../../utils/placesSession'
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers' 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) const acMetaRef = useRef<{ query: string; source: string } | null>(null)
// The name the whole list carries, for rows that do not name their own index. // The name the whole list carries, for rows that do not name their own index.
const [acSource, setAcSource] = useState('') const [acSource, setAcSource] = useState('')
// What answered the last full search, for the line that offers Google instead.
const [searchSource, setSearchSource] = useState('')
const hasMapsKey = useAuthStore(s => s.hasMapsKey)
const setResolving = useCallback( const setResolving = useCallback(
(v: boolean) => { (v: boolean) => {
@@ -168,7 +172,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
setQuery('') setQuery('')
} }
const handleSearch = async () => { const handleSearch = async (provider?: 'google') => {
const trimmed = query.trim() const trimmed = query.trim()
if (!trimmed) return if (!trimmed) return
setSuggestions([]) setSuggestions([])
@@ -200,9 +204,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
} }
// Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche // Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche
// braucht ihn genauso, nur als Punkt statt als Kasten. // 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' } searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setResults(result.places || []) setResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) { } catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally { } finally {
@@ -284,7 +289,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
/> />
<button <button
type="button" type="button"
onClick={handleSearch} onClick={() => handleSearch()}
disabled={searching} disabled={searching}
aria-label={t('common.search')} 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" 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 +337,20 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
))} ))}
</div> </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. */}
{results.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && (
<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> </div>
) )
} }
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' 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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel' import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel' import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -262,6 +262,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' 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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { import {
TRANSPORT_TYPE_COLOR, TRANSPORT_TYPE_COLOR,
@@ -280,6 +280,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
import type { ReservationTraveler } from '@trek/shared' import type { ReservationTraveler } from '@trek/shared'
import { avatarSrc } from '../../../../utils/avatarSrc' import { avatarSrc } from '../../../../utils/avatarSrc'
import GuestBadge from '../../../../components/shared/GuestBadge' 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, * 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 }} /> 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). * Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
* The mobile transport / booking cards only display them — assignment editing * 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> ? <button type="button" onClick={props.onAddPlace}>collections.addPlace</button>
: <div />, : <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/CopyToTripModal', () => ({ default: () => null }))
vi.mock('../components/Collections/MoveToListModal', () => ({ default: () => null })) vi.mock('../components/Collections/MoveToListModal', () => ({ default: () => null }))
vi.mock('../components/Collections/ShareCollectionModal', () => ({ 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() 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 isRealList = !c.isAllSaved && typeof c.activeId === 'number'
const canManageLabels = isRealList && c.canEdit const canManageLabels = isRealList && c.canEdit
// Selecting a place toggles it, so clicking it again — or the map background — // Selecting a place from the list toggles it, so clicking the row again clears
// clears it. Below the desktop breakpoint the list and map are separate views; // it, as does the map background. A marker click always shows the place: a second
// above it the list view is a split with a persistent map that pans to the // click on the marker of the place that is open read as "the details do not open"
// selection (the map stays mounted across the list↔map toggle so it animates). // 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 mappable = c.mappable
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id) const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
const showPlace = (id: number) => c.setSelectedPlaceId(id)
const deselect = () => c.setSelectedPlaceId(null) const deselect = () => c.setSelectedPlaceId(null)
const toggleView = () => { const toggleView = () => {
// Going to the full-map view closes the (list-docked) detail sheet. // 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 // Clicking a marker in the full-map view drops back to the split so the list
// + detail come into view alongside the map. // + 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 desktopSplit = c.isWide && c.hasMappable
const mapShown = c.hasMappable && (c.view === 'map' || c.isWide) const mapShown = c.hasMappable && (c.view === 'map' || c.isWide)
+27 -3
View File
@@ -10,6 +10,7 @@ import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map
import { DawarichTrailPill } from '../components/Map/DawarichTrailPill' import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
import { getCached, fetchPhoto } from '../services/photoService' import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import { DayPlanSidebarTransportDetailModal } from '../components/Planner/DayPlanSidebarTransportDetailModal'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch' import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
import TripLoadingSplash from '../components/shared/TripLoadingSplash' import TripLoadingSplash from '../components/shared/TripLoadingSplash'
import PlacesSidebar from '../components/Planner/PlacesSidebar' 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, // Page = wiring container: the entire planner state machine (store, tabs,
// selection, CRUD handlers with undo, map filters, splash) lives in the hook. // selection, CRUD handlers with undo, map filters, splash) lives in the hook.
const { 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, trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles, selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo, pushUndo, undo, canUndo, lastActionLabel, handleUndo,
@@ -296,7 +297,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
stayRelease, setStayRelease, confirmStayRelease, stayRelease, setStayRelease, confirmStayRelease,
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail, visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch, isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces, expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
@@ -445,7 +446,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
hasDayDetail={!!showDayDetail && !selectedPlace} hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations} reservations={reservations}
showReservationStats={true} 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) => { onReservationClick={(rid) => {
const r = reservations.find(x => x.id === rid) const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r) if (r) setMapTransportDetail(r)
@@ -596,6 +599,15 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onFocusPoint={focusRoadtripPoint} onFocusPoint={focusRoadtripPoint}
selectedAssignmentId={selectedAssignmentId} selectedAssignmentId={selectedAssignmentId}
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)} 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)
}}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined} onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined} onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined} onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
@@ -612,6 +624,18 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
collapsedDayIds={collapsedRoadtripDays} collapsedDayIds={collapsedRoadtripDays}
onToggleDay={toggleRoadtripDay} 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> </LazyPanel>
) : ( ) : (
<DayPlanSidebar <DayPlanSidebar
@@ -435,6 +435,20 @@ describe('AdminSettingsTab', () => {
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment'); 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-031: the API keys card leads with the TREK index and no weather panel', () => { it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
renderTab(); renderTab();
+18 -2
View File
@@ -25,6 +25,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
transitProvider, setTransitProviderState, transitProvider, setTransitProviderState,
transitGoogleKeySource, setTransitGoogleKeySource, transitGoogleKeySource, setTransitGoogleKeySource,
placeShadowEnabled, setPlaceShadowEnabledState, placeShadowEnabled, setPlaceShadowEnabledState,
@@ -448,8 +449,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<GoogleOptions <GoogleOptions
title={t('admin.googleOptions')} title={t('admin.googleOptions')}
summary={t('admin.googleOptionsSummary', { summary={t('admin.googleOptionsSummary', {
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled].filter(Boolean).length, on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled, placesGoogleOnly].filter(Boolean).length,
total: 4, total: 5,
})} })}
> >
<div className="divide-y divide-edge-faint"> <div className="divide-y divide-edge-faint">
@@ -520,6 +521,21 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
}} }}
/> />
</div> </div>
{/* The one row here that is about where a search goes rather than
what the key may be spent on. Without a key 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(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}</p>
</div>
<ToggleSwitch
on={placesGoogleOnly}
label={t('admin.placesGoogleOnly.title')}
onToggle={handleTogglePlacesGoogleOnly}
/>
</div>
</div> </div>
</GoogleOptions> </GoogleOptions>
</ProviderBlock> </ProviderBlock>
+11
View File
@@ -105,6 +105,16 @@ export function useAdmin() {
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
useEffect(() => { adminApi.getPlacesEnrich().then(d => setPlacesEnrichEnabledState(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(() => {}) }, [])
const handleTogglePlacesGoogleOnly = async () => {
const next = !placesGoogleOnly
setPlacesGoogleOnlyState(next)
try { await adminApi.updatePlacesGoogleOnly(next) } catch { setPlacesGoogleOnlyState(!next) }
}
// Transit backend (#1699). googleKeySource says where a Google key would come // Transit backend (#1699). googleKeySource says where a Google key would come
// from for this admin — null means picking Google changes nothing, since the // from for this admin — null means picking Google changes nothing, since the
// request-time fallback to Transitous is silent by design. // request-time fallback to Transitous is silent by design.
@@ -481,6 +491,7 @@ export function useAdmin() {
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
transitProvider, setTransitProviderState, transitProvider, setTransitProviderState,
transitGoogleKeySource, setTransitGoogleKeySource, transitGoogleKeySource, setTransitGoogleKeySource,
placeShadowEnabled, setPlaceShadowEnabledState, placeShadowEnabled, setPlaceShadowEnabledState,
@@ -4,6 +4,7 @@ import {
distanceBetweenGeoPoints, distanceBetweenGeoPoints,
groupPhotosByDate, groupPhotosByDate,
matchJourneyEntries, matchJourneyEntries,
posterlessVideo,
sortProviderPhotos, sortProviderPhotos,
utcOffsetMinutesForDay, utcOffsetMinutesForDay,
} from './JourneyDetailPage.helpers'; } from './JourneyDetailPage.helpers';
@@ -207,3 +208,20 @@ describe('matchJourneyEntries', () => {
expect(matchJourneyEntries(entries, 'reykjavik')).toEqual([]); 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}`; 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. * Which calendar day a provider photo belongs to.
* *
+31 -10
View File
@@ -30,6 +30,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries' import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag' import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow' import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { carrierReservationIds, type CarrierTerminal } from '@trek/shared/roadtrip'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail' import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { collapsedDayDates } from '../../components/Map/dawarichTrail' import { collapsedDayDates } from '../../components/Map/dawarichTrail'
@@ -76,6 +77,7 @@ import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlace
/** Stable empty list so the road trip hook stays inert while its mode is off. */ /** Stable empty list so the road trip hook stays inert while its mode is off. */
const EMPTY_DAYS: Day[] = [] const EMPTY_DAYS: Day[] = []
const EMPTY_RESERVATIONS: Reservation[] = []
/** /**
* Trip planner page logic — the big one. Owns the trip store wiring, addon * Trip planner page logic — the big one. Owns the trip store wiring, addon
@@ -93,7 +95,7 @@ export function useTripPlanner() {
const tripId = id ? Number(id) : Number.NaN const tripId = id ? Number(id) : Number.NaN
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const { t, language } = useTranslation() const { t, language, locale } = useTranslation()
const { settings } = useSettingsStore() const { settings } = useSettingsStore()
const roadtripSettings = useRoadtripSettings(s => s, tripId) const roadtripSettings = useRoadtripSettings(s => s, tripId)
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped). // trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
@@ -743,7 +745,18 @@ export function useTripPlanner() {
roadtripVias.byDay, roadtripVias.byDay,
dayBoundaries.boundaries, dayBoundaries.boundaries,
tripAccommodations, 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. // 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 // 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. // 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. * looks exactly like a drag that did nothing.
*/ */
const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => { 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) { for (const day of roadtripRoutes.days) {
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored // 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 // 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)) { if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = arrivedFrom.ownerDayId ?? day.dayId const owner = arrivedFrom.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue 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 continue
} }
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1 const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
@@ -1699,7 +1716,7 @@ export function useTripPlanner() {
const owner = anchor.ownerDayId ?? day.dayId const owner = anchor.ownerDayId ?? day.dayId
// A drag stays on its own day; a fresh click may land wherever it landed. // A drag stays on its own day; a fresh click may land wherever it landed.
if (onlyDayId !== undefined && owner !== onlyDayId) continue 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 return best
}, [roadtripRoutes.days]) }, [roadtripRoutes.days])
@@ -1720,7 +1737,9 @@ export function useTripPlanner() {
*/ */
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => { const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
const anchor = anchorFor(lat, lng) 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) { for (const day of roadtripRoutes.days) {
const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) 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 } 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) => { const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
if (!can('day_edit', trip)) return if (!can('day_edit', trip)) return
const best = anchorFor(lat, lng) const best = anchorFor(lat, lng)
// A click that landed on some other line is not a via anywhere. // A click that landed on some other line is not a via anywhere. Neither is one on a
if (!best || best.offRouteKm > 2) return // 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 { try {
await roadtripVias.add(best.dayId, best.afterIndex, lat, lng) await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
} catch (err: unknown) { } catch (err: unknown) {
@@ -1826,7 +1847,7 @@ export function useTripPlanner() {
// just says which leg gets bent, and the router answers the rest. // just says which leg gets bent, and the router answers the rest.
const anchor = anchorFor(lat, lng, dayId) const anchor = anchorFor(lat, lng, dayId)
try { 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) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} }
@@ -2592,7 +2613,7 @@ export function useTripPlanner() {
}, [isLoading, trip]) }, [isLoading, trip])
return { 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, trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles, selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo, pushUndo, undo, canUndo, lastActionLabel, handleUndo,
@@ -2642,7 +2663,7 @@ export function useTripPlanner() {
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey, routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail, visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch, isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces, expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
@@ -252,11 +252,17 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
} }
// Genuinely new — including a legitimate second assignment of a place // Genuinely new — including a legitimate second assignment of a place
// already on this day (no temp version to reconcile). Append. // already on this day (no temp version to reconcile). Seated where the server
// put it: a booked night lands at the front of its day, and the rows behind it
// were moved up one there. Appending it with that index would leave it tied
// with the old first row and drawn second until the next reload.
const ordered = existing.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 { return {
assignments: { assignments: {
...state.assignments, ...state.assignments,
[dayKey]: [...existing, incoming], [dayKey]: ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i })),
} }
} }
}, },
+13 -2
View File
@@ -187,6 +187,10 @@
.trek-dash .hero-content { .trek-dash .hero-content {
position: absolute; inset: 0; z-index: 2; padding: 32px 32px 26px; position: absolute; inset: 0; z-index: 2; padding: 32px 32px 26px;
display: grid; grid-template-rows: auto 1fr auto; color: #fff; 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-top { display: flex; justify-content: space-between; align-items: start; }
.trek-dash .hero-badge { .trek-dash .hero-badge {
@@ -221,14 +225,21 @@
} }
.trek-dash .hero-tool:hover { background: oklch(0.22 0.01 260 / .78); transform: scale(1.05); } .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-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 { .trek-dash .hero-eyebrow {
display: inline-flex; align-items: center; gap: 10px; display: inline-flex; align-items: center; gap: 10px;
font-size: 11.5px; letter-spacing: 0.22em; text-transform: uppercase; font-size: 11.5px; letter-spacing: 0.22em; text-transform: uppercase;
opacity: .88; margin-bottom: 16px; font-weight: 500; 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-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: 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);
/* 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;
}
/* ----------------- boarding pass ----------------- */ /* ----------------- boarding pass ----------------- */
/* The pass is split into an outer wrapper (left notch) and an inner glass panel /* The pass is split into an outer wrapper (left notch) and an inner glass panel
+27
View File
@@ -0,0 +1,27 @@
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 block = (selector: string): string => {
const at = css.indexOf(selector)
expect(at, `${selector} missing from dashboard.css`).toBeGreaterThan(-1)
return css.slice(at, css.indexOf('}', at) + 1)
}
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/)
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { offersGoogleRetry, sourceLabelFor } from './placeSource'
describe('offersGoogleRetry', () => {
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where a key exists', () => {
expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('openstreetmap', true)).toBe(true)
expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true)
expect(offersGoogleRetry('trek-places+openstreetmap', false)).toBe(false)
})
it('FE-PLACESOURCE-002: a list Google produced, the offline cache and no list at all have nowhere further to go', () => {
expect(offersGoogleRetry('google', true)).toBe(false)
expect(offersGoogleRetry('google+plugin', true)).toBe(false)
expect(offersGoogleRetry('offline-cache', true)).toBe(false)
expect(offersGoogleRetry('', true)).toBe(false)
})
})
describe('sourceLabelFor', () => {
it('FE-PLACESOURCE-003: a row names its own index, else the list it came from', () => {
const t = (k: string) => k
expect(sourceLabelFor({ source: 'openstreetmap' }, 'trek-places+openstreetmap', t)).toBe('OpenStreetMap')
expect(sourceLabelFor({}, 'google', t)).toBe('Google')
expect(sourceLabelFor({}, 'amap', t)).toBe('places.source.amap')
})
})
+15
View File
@@ -40,6 +40,21 @@ const SOURCE_KEYS: Record<string, string> = {
* answered the call: Google never marks its places, and a merged list marks only * answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination. * the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/ */
/**
* Whether a result list can be sent to Google instead.
*
* The index and OpenStreetMap answer first, and Google is only asked when they
* find nothing, so a list that has the wrong place on it never reaches Google
* on its own. The link that sends it there is offered when the instance has a
* Google key and this list did not already come from Google: a list Google
* produced, alone or as the empty-case fallback, has nowhere further to go, and
* the offline cache is not a search at all.
*/
export function offersGoogleRetry(listSource: string, hasMapsKey: boolean): boolean {
if (!hasMapsKey || !listSource) return false
return !listSource.split('+').includes('google') && listSource !== 'offline-cache'
}
export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null { export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null {
const own = (place as { source?: unknown } | null)?.source const own = (place as { source?: unknown } | null)?.source
const source = typeof own === 'string' && own const source = typeof own === 'string' && own
+2
View File
@@ -76,6 +76,8 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
placesDetailsEnabled: false, placesDetailsEnabled: false,
setPlacesDetailsEnabledState: vi.fn(), setPlacesDetailsEnabledState: vi.fn(),
placesEnrichEnabled: true, placesEnrichEnabled: true,
placesGoogleOnly: false,
handleTogglePlacesGoogleOnly: vi.fn(),
setPlacesEnrichEnabledState: vi.fn(), setPlacesEnrichEnabledState: vi.fn(),
// Fail-open on the server, so the harness starts where an unset row lands. // Fail-open on the server, so the harness starts where an unset row lands.
trekPlacesEnabled: true, trekPlacesEnabled: true,
@@ -3,17 +3,27 @@ import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018 // FE-MOB-BKTAB-001 to FE-MOB-BKTAB-023
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const SEAT_MAP = { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -93,6 +103,7 @@ function cardOf(title: string): HTMLElement {
describe('MBookingsTab', () => { describe('MBookingsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => { it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
@@ -285,4 +296,29 @@ describe('MBookingsTab', () => {
// the check-in cell and the time field both read 09:00 // the check-in cell and the time field both read 09:00
expect(within(card).getAllByText('09:00')).toHaveLength(2) expect(within(card).getAllByText('09:00')).toHaveLength(2)
}) })
it('FE-MOB-BKTAB-022: mounts a reservation-detail plugin on every booking card, scoped to that booking (#2440)', () => {
seedStore(usePluginStore, {
plugins: [SEAT_MAP, { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'sidebar' }],
})
const { planner: p } = renderTab()
const frame = within(cardOf('Hotel Granvia')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'seat-map')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '201')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no editor
fireEvent.click(frame)
expect(p.setShowReservationModal).not.toHaveBeenCalled()
expect(within(cardOf('Bamboo walk')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '204')
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(4)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
})
it('FE-MOB-BKTAB-023: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [SEAT_MAP] })
renderTab(planner(), buildShell({ bookingsCompact: true }))
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
}) })
@@ -3,17 +3,27 @@ import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransports
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022 // FE-MOB-TRTAB-001 to FE-MOB-TRTAB-028
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const FLIGHT_TRACKER = { id: 'flight-tracker', name: 'Flight Tracker', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -101,6 +111,7 @@ function cardOf(title: string): HTMLElement {
describe('MTransportsTab', () => { describe('MTransportsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => { it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
@@ -322,4 +333,35 @@ describe('MTransportsTab', () => {
// nobody is assigned, so the traveler filter row stays away // nobody is assigned, so the traveler filter row stays away
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument() expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
}) })
it('FE-MOB-TRTAB-027: mounts a reservation-detail plugin on every card, scoped to that reservation (#2440)', () => {
seedStore(usePluginStore, {
plugins: [
FLIGHT_TRACKER,
{ id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' },
{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'place-detail' },
],
})
const { shell } = renderTab()
const frame = within(cardOf('HND to ITM')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'flight-tracker')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '101')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no sheet
fireEvent.click(frame)
expect(shell.openSheet).not.toHaveBeenCalled()
expect(within(cardOf('Metro to hotel')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '104')
// one frame per transport card, and only for the reservation-detail slot
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(5)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
expect(document.querySelector('[data-plugin="seat-map"]')).toBeNull()
})
it('FE-MOB-TRTAB-028: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [FLIGHT_TRACKER] })
renderTab(planner(), buildShell({ transportsCompact: true }))
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
}) })
+2 -2
View File
@@ -2,7 +2,7 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
import { rtlTextAlias } from './rtlTextAlias.js'; import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
// The version this bundle is built as, baked in at build time. The release image // The version this bundle is built as, baked in at build time. The release image
@@ -259,7 +259,7 @@ export default defineConfig(({ mode }) => ({
}, },
}), }),
].filter(Boolean), ].filter(Boolean),
resolve: { alias: [rtlTextAlias] }, resolve: { alias: [rtlTextAlias, plyrSpriteAlias] },
build: { build: {
// Pin the output level instead of inheriting whatever the current Vite default // Pin the output level instead of inheriting whatever the current Vite default
// is, so a toolchain bump can't silently change which browsers still work. // is, so a toolchain bump can't silently change which browsers still work.
+2 -2
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { rtlTextAlias } from './rtlTextAlias.js'; import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
const UI_VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; const UI_VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
@@ -9,7 +9,7 @@ export default defineConfig({
define: { __TREK_UI_VERSION__: JSON.stringify(UI_VERSION) }, define: { __TREK_UI_VERSION__: JSON.stringify(UI_VERSION) },
plugins: [react()], plugins: [react()],
test: { test: {
alias: [rtlTextAlias], alias: [rtlTextAlias, plyrSpriteAlias],
root: '.', root: '.',
globals: true, globals: true,
environment: './tests/environment/jsdom-native-abort.ts', environment: './tests/environment/jsdom-native-abort.ts',
+20 -329
View File
@@ -9321,26 +9321,6 @@
"bare-path": "^3.0.0" "bare-path": "^3.0.0"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.22", "version": "2.11.22",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz",
@@ -9360,20 +9340,6 @@
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bidi-js": { "node_modules/bidi-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -9396,15 +9362,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/birpc": { "node_modules/birpc": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz",
@@ -9415,17 +9372,6 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bluebird": { "node_modules/bluebird": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -9555,30 +9501,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": { "node_modules/buffer-crc32": {
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -9855,12 +9777,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cli-width": { "node_modules/cli-width": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
@@ -10406,30 +10322,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -10573,6 +10465,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -10828,15 +10721,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -11473,15 +11357,6 @@
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -11797,12 +11672,6 @@
"url": "https://github.com/sindresorhus/file-type?sponsor=1" "url": "https://github.com/sindresorhus/file-type?sponsor=1"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filelist": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -12029,12 +11898,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "11.3.5", "version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
@@ -12242,12 +12105,6 @@
"omggif": "^1.0.10" "omggif": "^1.0.10"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gl-matrix": { "node_modules/gl-matrix": {
"version": "3.4.4", "version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
@@ -12765,12 +12622,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inline-style-parser": { "node_modules/inline-style-parser": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -15464,18 +15315,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": { "node_modules/min-indent": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -15521,12 +15360,6 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -15788,12 +15621,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -15830,16 +15657,13 @@
} }
} }
}, },
"node_modules/node-abi": { "node_modules/node-addon-api": {
"version": "3.92.0", "version": "8.9.2",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"license": "MIT", "license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": { "engines": {
"node": ">=10" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-int64": { "node_modules/node-int64": {
@@ -16764,33 +16588,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -17048,16 +16845,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -17390,21 +17177,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.6", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -18623,51 +18395,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-update-notifier": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -19038,15 +18765,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strnum": { "node_modules/strnum": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
@@ -19286,34 +19004,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
@@ -19834,18 +19524,6 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -21783,7 +21461,7 @@
"@trek/shared": "*", "@trek/shared": "*",
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^13.0.3",
"compression": "^1.8.0", "compression": "^1.8.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -21950,6 +21628,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"server/node_modules/better-sqlite3": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
},
"engines": {
"node": ">=22"
}
},
"server/node_modules/brace-expansion": { "server/node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+1 -1
View File
@@ -40,7 +40,7 @@
"@trek/shared": "*", "@trek/shared": "*",
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^13.0.3",
"compression": "^1.8.0", "compression": "^1.8.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
+14
View File
@@ -2,6 +2,7 @@ import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
import { readEnv } from '../app-config'; import { readEnv } from '../app-config';
import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
import { seedDocumentProviders } from './document-provider-seed'; import { seedDocumentProviders } from './document-provider-seed';
import { reseatBookedNights } from './reseat-booked-nights';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import fs from 'fs'; import fs from 'fs';
@@ -5212,6 +5213,19 @@ function runMigrations(db: Database.Database): void {
ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries; ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries;
`); `);
}, },
/*
* Seat every booked night where its check-in says, the way a night booked
* today is seated. The night leads its day now; the stops a booking put on
* their check-in day before this release sit last, and the trips people
* already have would keep that order until somebody edits the check-in.
* The rules, and the drawn roads that follow the stops, are in
* reseat-booked-nights.ts. Re-runnable.
*/
() => {
const seated = reseatBookedNights(db);
if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`);
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+118
View File
@@ -0,0 +1,118 @@
import Database from 'better-sqlite3';
type DayRow = { id: number; at: string | null; located: number };
type Via = { id: number; after_order_index: number; sequence: number };
/**
* Seat every booked night where its check-in says, the way a night booked today is
* seated (AccommodationsService.positionForCheckIn).
*
* The stop a booking puts on its check-in day used to go last unless a stop pinned to
* a later hour pulled it forward, and the backfill that gave older bookings their stop
* appended it too. The night leads its day now: first, behind only a stop whose own
* clock is at or before the check-in, with the stops that carry no hour following it.
* Without this step every trip planned before the change keeps the old order until
* somebody edits the check-in.
*
* Only the stops a booking owns move (accommodation_id). A stop the traveller placed
* themselves and then booked a night on is theirs, wherever it sits. The drawn roads
* (roadtrip_vias) are pinned to positions among the day's located stops, so they are
* carried along by the rules the service applies (AccommodationsService.reanchorVias):
* a via follows the stop it was drawn after, a via behind a stop that is last stays
* with it, and one left without a leg goes.
*
* Runs inside the migration's transaction. Re-runnable: a night already in its seat is
* passed over. Returns how many nights moved.
*/
export function reseatBookedNights(db: Database.Database): number {
const nights = db.prepare(`
SELECT da.id, da.day_id, a.check_in
FROM day_assignments da
JOIN day_accommodations a ON a.id = da.accommodation_id
ORDER BY da.day_id, da.order_index, da.id
`).all() as Array<{ id: number; day_id: number; check_in: string | null }>;
// Another night on the same day counts by its check-in, so two bookings on one
// day settle by the clock instead of by whichever was seated last.
const dayRows = db.prepare(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at,
(p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located
FROM day_assignments da JOIN places p ON p.id = da.place_id
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
`);
const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?');
const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ?');
const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ?');
const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ?');
let seated = 0;
for (const night of nights) {
const rows = dayRows.all(night.day_id) as DayRow[];
const own = rows.findIndex((row) => row.id === night.id);
if (own < 0) continue;
const others = rows.filter((row) => row.id !== night.id);
let seat = 0;
if (night.check_in) {
others.forEach((row, i) => {
if (row.at !== null && row.at <= night.check_in!) seat = i + 1;
});
}
const next = [...others.slice(0, seat), rows[own]!, ...others.slice(seat)];
if (next.every((row, i) => row.id === rows[i]!.id)) continue;
const previousLocated = rows.filter((row) => row.located).map((row) => row.id);
next.forEach((row, i) => setIndex.run(i, row.id));
seated += 1;
const nextLocated = next.filter((row) => row.located).map((row) => row.id);
const vias = viasOf.all(night.day_id) as Via[];
if (!vias.length) continue;
const previousLast = previousLocated.length - 1;
const nextLast = nextLocated.length - 1;
const lastStayed = previousLast >= 0 && previousLocated[previousLast] === nextLocated[nextLast];
const landed = new Set<number>();
const kept: Array<Via & { was: number }> = [];
for (const via of vias) {
const leg = lastStayed && via.after_order_index === previousLast
? nextLast
: legAfter(via.after_order_index, previousLocated, nextLocated);
if (leg === null) {
dropVia.run(via.id);
continue;
}
if (leg !== via.after_order_index) {
moveVia.run(leg, via.id);
landed.add(via.id);
}
kept.push({ ...via, was: via.after_order_index, after_order_index: leg });
}
// Two legs that merged carry two sequence series side by side. Renumbered the way
// the service does it: the earlier leg's points first, then by their old sequence,
// then by id. Only a leg that received a via is touched.
const byLeg = new Map<number, Array<Via & { was: number }>>();
for (const via of kept) byLeg.set(via.after_order_index, [...(byLeg.get(via.after_order_index) ?? []), via]);
for (const onLeg of byLeg.values()) {
if (!onLeg.some((via) => landed.has(via.id))) continue;
onLeg.sort((a, b) => a.was - b.was || a.sequence - b.sequence || a.id - b.id);
onLeg.forEach((via, i) => {
if (via.sequence !== i) setSequence.run(i, via.id);
});
}
}
return seated;
}
/**
* The leg a via pinned behind the n-th stop of the old order is on in the new one
* (AccommodationsService.legAfter). Null when no leg is left for it.
*/
function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null {
if (index > previousIds.length - 1) return null;
let at = index;
while (at >= 0 && !nextIds.includes(previousIds[at]!)) at -= 1;
if (at < 0) return null;
const next = nextIds.indexOf(previousIds[at]!);
return next >= nextIds.length - 1 ? null : next;
}
@@ -265,6 +265,13 @@ export function applyGlobalMiddleware(
], ],
workerSrc: ["'self'", "blob:"], workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"], childSrc: ["'self'", "blob:"],
// blob: because a picked clip is previewed and its poster frame grabbed
// through a <video> on an object URL, before any byte reaches the server.
// Unset, this fell back to default-src, which refuses blob: outright: the
// editor showed nothing and every clip landed without a poster, so its
// thumbnail answered 404 (#2341). Invisible in dev, where Vite serves the
// document without this header.
mediaSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
// 'self' so same-origin file previews can embed PDFs via <object>/<embed> // 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there). // (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
+2 -2
View File
@@ -24,7 +24,7 @@ export class ThingsMcp {
@Tool({ @Tool({
name: 'list_things', name: 'list_things',
description: 'List all things.', description: 'List all things.',
inputSchema: {}, // ZodRawShape, passed straight to the SDK inputSchema: {}, // ZodRawShape, registered as a strict object
annotations: { readOnlyHint: true }, annotations: { readOnlyHint: true },
access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy
}) })
@@ -34,7 +34,7 @@ export class ThingsMcp {
} }
``` ```
- `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared. - `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared. A shape is registered as `z.strictObject`, so an argument the tool never declared is refused (`Unrecognized key`) and `tools/list` advertises `additionalProperties: false`; the SDK's own wrapping would strip it and run the handler regardless. A whole Zod schema is passed through as written.
- `@Resource(options)` — fixed URI (`uri`, `mimeType`, …). Handler: `(uri: URL, ctx)`. - `@Resource(options)` — fixed URI (`uri`, `mimeType`, …). Handler: `(uri: URL, ctx)`.
- `@ResourceTemplate(options)` — `uriTemplate` (RFC 6570). Handler: `(uri: URL, variables, ctx)`. (No `list`/`complete` template callbacks yet — extend when a domain needs them.) - `@ResourceTemplate(options)` — `uriTemplate` (RFC 6570). Handler: `(uri: URL, variables, ctx)`. (No `list`/`complete` template callbacks yet — extend when a domain needs them.)
- `@Prompt(options)` — mirrors `registerPrompt` (`argsSchema` entries must be string-valued Zod schemas). Handler: `(args, ctx)`. - `@Prompt(options)` — mirrors `registerPrompt` (`argsSchema` entries must be string-valued Zod schemas). Handler: `(args, ctx)`.
+16 -4
View File
@@ -15,12 +15,24 @@ import type {
} from './types'; } from './types';
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
interface BoundEntry { interface BoundEntry {
entry: McpEntry; entry: McpEntry;
instance: object; instance: object;
} }
/**
* A shape is registered strict: the SDK's own z.object wrapping would strip an
* undeclared key and run the handler as if nothing was wrong, and strict also
* puts `additionalProperties: false` into tools/list. A whole schema is the
* author's call and passes through untouched.
*/
function strictInputSchema(schema: ToolOptions['inputSchema']): unknown {
if (schema === undefined || '_zod' in schema) return schema;
return z.strictObject(schema);
}
export interface McpRegistryOptions { export interface McpRegistryOptions {
accessPolicy?: McpAccessPolicy; accessPolicy?: McpAccessPolicy;
validateAccess?: McpAccessValidator; validateAccess?: McpAccessValidator;
@@ -30,9 +42,9 @@ type AnyHandler = (this: unknown, ...handlerArgs: unknown[]) => unknown;
/** /**
* Structural view of the SDK registration surface. The SDK's real signatures * Structural view of the SDK registration surface. The SDK's real signatures
* are generic over the Zod shapes; the registry passes schemas straight * are generic over the Zod shapes; the registry hands schemas over as objects
* through and binds `ctx` where the SDK would pass `extra`, so it needs (and * and binds `ctx` where the SDK would pass `extra`, so it needs (and exposes)
* exposes) none of that type-level machinery. * none of that type-level machinery.
*/ */
interface LooseRegistrar { interface LooseRegistrar {
registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown; registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
@@ -294,7 +306,7 @@ export class McpRegistry {
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
title: options.title, title: options.title,
description: options.description, description: options.description,
inputSchema: options.inputSchema, inputSchema: strictInputSchema(options.inputSchema),
outputSchema: options.outputSchema, outputSchema: options.outputSchema,
annotations: options.annotations, annotations: options.annotations,
_meta: options._meta, _meta: options._meta,
+3 -2
View File
@@ -126,8 +126,9 @@ export interface McpZodSchema {
} }
/** /**
* Mirrors the SDK's `registerTool` config. `inputSchema` is passed straight * Mirrors the SDK's `registerTool` config. A shape `inputSchema` is registered
* through — the SDK does the parsing/validation. * as a strict object (see registry.ts); a whole schema is passed as written. The
* SDK does the parsing/validation either way.
* `annotations`/`_meta` are kept structural so the package's public d.ts * `annotations`/`_meta` are kept structural so the package's public d.ts
* never imports deep SDK subpaths. * never imports deep SDK subpaths.
*/ */
@@ -164,9 +164,16 @@ export class AccommodationsService {
return this.mirrorStay(accommodationId, placeId, dayId, checkIn); return this.mirrorStay(accommodationId, placeId, dayId, checkIn);
} }
/** Carry a stay's own stop over to where the stay now is. */ /** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the
moveStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { * booking was given a new hour, which seats the night afresh (see remirrorStay). */
return this.remirrorStay(accommodationId, placeId, dayId, checkIn); moveStayStop(
accommodationId: number,
placeId: number | null,
dayId: number,
checkIn?: string | null,
opts: { checkInChanged?: boolean } = {},
): AccommodationMirror {
return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts);
} }
/** Take back the stops of a stay that is being deleted elsewhere. */ /** Take back the stops of a stay that is being deleted elsewhere. */
@@ -277,42 +284,47 @@ export class AccommodationsService {
/** /**
* Where in the day the booked night belongs. * Where in the day the booked night belongs.
* *
* Last, unless the booking names an hour. A night usually ends the day, which is why * First. The hotel is where the day is based: the traveller checks in and the stops
* that is the default — but a check-in at eleven says the traveller is at the desk * they placed without an hour follow from there. Only a stop with a clock of its
* before whatever they pinned to the afternoon, and dropping the stop behind it built * own can stand ahead of it, and only when that clock is at or before the check-in:
* a drive that visits the hotel after the sixteen-hundred it was booked around. * a stop pinned to eight with a check-in at ten puts the night second, one pinned
* * to the afternoon does not. It used to go last unless something pinned later
* Only stops that carry a clock of their own are compared. One without a time takes * pulled it forward, and a night booked for ten in the morning sat behind a whole
* its position from the chain, not from the hour, so pushing past it would move a * day of unpinned stops, reached at a quarter past twelve.
* stop the traveller placed deliberately.
*/ */
private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number | undefined { private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number {
if (!checkIn) return undefined; if (!checkIn) return 0;
// excludeId leaves the row being re-seated out of the chain it is measured // excludeId leaves the row being re-seated out of the chain it is measured
// against. Without it a night parked at the end of the day can find itself. // against. Without it a night parked at the end of the day can find itself.
// Another booked night on the day counts by its check-in, so two bookings on
// one day settle by the clock.
const rows = this.db.all<{ order_index: number; at: string | null }>(` const rows = this.db.all<{ order_index: number; at: string | null }>(`
SELECT da.order_index, COALESCE(da.assignment_time, p.place_time) AS at SELECT da.order_index, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at
FROM day_assignments da JOIN places p ON p.id = da.place_id FROM day_assignments da JOIN places p ON p.id = da.place_id
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index
`, dayId, excludeId ?? -1); `, dayId, excludeId ?? -1);
const later = rows.find(row => row.at !== null && row.at > checkIn); let seat = 0;
return later?.order_index; for (const row of rows) if (row.at !== null && row.at <= checkIn) seat = row.order_index + 1;
return seat;
} }
/** /**
* Whether the night already sits where its check-in says it should. * Whether the night sits somewhere its check-in allows, on an edit that did not
* touch the check-in.
* *
* Read off the chain rather than recomputed, because the index a fresh insert would * Read off the chain rather than recomputed, because the index a fresh insert would
* get is not the index this row already occupies. Two ways to be wrong: something * get is not the index this row already occupies. Two ways to be wrong: something
* with a later hour ahead of it, or something with an earlier one behind it. Stops * with a later hour ahead of it, or something with an earlier one behind it. Stops
* without an hour are passed over in both directions — their place in the chain is * without an hour are passed over in both directions: the night may have been
* the traveller's doing, not a clock's. * dragged past them on purpose, and a change of notes is no reason to undo that.
*/ */
private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean { private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean {
if (!checkIn) return true; if (!checkIn) return true;
const rows = this.db.all<{ id: number; at: string | null }>(` const rows = this.db.all<{ id: number; at: string | null }>(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time) AS at SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at
FROM day_assignments da JOIN places p ON p.id = da.place_id FROM day_assignments da JOIN places p ON p.id = da.place_id
LEFT JOIN day_accommodations other ON other.id = da.accommodation_id
WHERE da.day_id = ? ORDER BY da.order_index WHERE da.day_id = ? ORDER BY da.order_index
`, dayId); `, dayId);
const own = rows.findIndex(row => row.id === ownId); const own = rows.findIndex(row => row.id === ownId);
@@ -493,7 +505,7 @@ export class AccommodationsService {
this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id); this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id);
const seat = this.positionForCheckIn(dayId, checkIn, stop.id); const seat = this.positionForCheckIn(dayId, checkIn, stop.id);
if (seat !== undefined && seat < end) { if (seat < end) {
this.db.run( this.db.run(
'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?', 'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?',
dayId, seat, stop.id, dayId, seat, stop.id,
@@ -519,9 +531,8 @@ export class AccommodationsService {
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror; if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror;
const before = this.stopOrders([dayId]); const before = this.stopOrders([dayId]);
// Through AssignmentsService, so the stop lands at the end of the day with the // Through AssignmentsService, seated where the check-in says (see
// order_index every other new assignment gets. Evening is where you arrive at a // positionForCheckIn), with everything behind it moved up one.
// hotel, and the day planner has no drive to position it against anyway.
// //
// The booking id goes in with the INSERT, not as an UPDATE afterwards: what this // The booking id goes in with the INSERT, not as an UPDATE afterwards: what this
// returns is the row the answer hands the client, and stamping the id on later // returns is the row the answer hands the client, and stamping the id on later
@@ -590,14 +601,24 @@ export class AccommodationsService {
* *
* Runs inside the caller's transaction. * Runs inside the caller's transaction.
*/ */
private remirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { private remirrorStay(
accommodationId: number,
placeId: number | null,
dayId: number,
checkIn?: string | null,
opts: { checkInChanged?: boolean } = {},
): AccommodationMirror {
const own = this.ownStops(accommodationId); const own = this.ownStops(accommodationId);
if (own.length === 0) return noMirror(); if (own.length === 0) return noMirror();
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) { if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
// Same place, same day — but a check-in moved to a different hour can still put // Same place, same day. A check-in given a new hour seats the night afresh, the
// the night somewhere else in the chain, and a stop sitting after the afternoon // way booking it with that hour would have; any other edit leaves a stop where
// it was booked around is the plan reading back wrong. // it is unless the clocks around it say it is in the wrong place, so a night the
if (this.seatedByCheckIn(dayId, own[0].id, checkIn)) return noMirror(); // traveller dragged somewhere stays there through a change of notes.
const settled = opts.checkInChanged
? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index
: this.seatedByCheckIn(dayId, own[0].id, checkIn);
if (settled) return noMirror();
} }
const mirror = noMirror(); const mirror = noMirror();
@@ -689,7 +710,9 @@ export class AccommodationsService {
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?', 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
); );
return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn); return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, {
checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null),
});
}); });
// Sync check-in/out/confirmation to every linked reservation. The booking form // Sync check-in/out/confirmation to every linked reservation. The booking form
+9
View File
@@ -198,6 +198,15 @@ export class AddonsService {
getPlaceShadow() { return this.readFlag('place_shadow_enabled'); } getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); } updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
/**
* Search and suggestions from Google alone, skipping the index and
* OpenStreetMap. Fail-closed for the same reason as the shadow log: nobody had
* this before it existed, so an absent row correctly means off. MapsService
* reads the same key the same way, and only once Google holds the key slot.
*/
getPlacesGoogleOnly() { return this.readFlag('places_google_only'); }
updatePlacesGoogleOnly(enabled: boolean) { return this.writeFlag('places_google_only', enabled); }
/** /**
* Enrichment reads fail-OPEN, unlike the three switches above. * Enrichment reads fail-OPEN, unlike the three switches above.
* *
+10
View File
@@ -243,6 +243,16 @@ export class AdminController {
return result; return result;
} }
@Get('places-google-only')
getPlacesGoogleOnly() { return this.addons.getPlacesGoogleOnly(); }
@Put('places-google-only')
updatePlacesGoogleOnly(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesGoogleOnly(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_google_only', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-enrich') @Get('places-enrich')
getPlacesEnrich() { return this.addons.getPlacesEnrich(); } getPlacesEnrich() { return this.addons.getPlacesEnrich(); }
+2 -2
View File
@@ -25,13 +25,13 @@ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
* signed like the REST contract's (#2176): a negative payer is the recipient * signed like the REST contract's (#2176): a negative payer is the recipient
* of a refund recorded as a negative expense. * of a refund recorded as a negative expense.
*/ */
const payersSchema = z.array(z.object({ const payersSchema = z.array(z.strictObject({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
amount: z.number(), amount: z.number(),
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.'); })).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
/** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */ /** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */
const splitMembersSchema = z.array(z.object({ const splitMembersSchema = z.array(z.strictObject({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
amount: z.number(), amount: z.number(),
})).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.'); })).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.');
+1 -1
View File
@@ -63,7 +63,7 @@ type EntryVisibility = z.infer<typeof ENTRY_VISIBILITY>;
* only the half it has, which is what the entry editor does when a place was * only the half it has, which is what the entry editor does when a place was
* all good or all bad. The service stores nothing when both come back empty. * all good or all bad. The service stores nothing when both come back empty.
*/ */
const PROS_CONS = z.object({ const PROS_CONS = z.strictObject({
pros: z.array(z.string()).default([]), pros: z.array(z.string()).default([]),
cons: z.array(z.string()).default([]), cons: z.array(z.string()).default([]),
}); });
+1 -1
View File
@@ -76,7 +76,7 @@ export class MapsController {
@Query('lang') lang?: string, @Query('lang') lang?: string,
): Promise<MapsSearchResult> { ): Promise<MapsSearchResult> {
try { try {
return await this.maps.search(user.id, body.query, lang, body.locationBias); return await this.maps.search(user.id, body.query, lang, body.locationBias, body.provider);
} catch (err: unknown) { } catch (err: unknown) {
console.error('Maps search error:', err); console.error('Maps search error:', err);
throw toHttpException(err, 'Search error', 500); throw toHttpException(err, 'Search error', 500);
+1 -1
View File
@@ -95,7 +95,7 @@ export class MapsMcp {
z.enum(POI_CATEGORY_KEYS), z.enum(POI_CATEGORY_KEYS),
z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES), z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES),
]).describe('Which kind of place to look for. Pass several to cover them in one query rather than one request per kind'), ]).describe('Which kind of place to look for. Pass several to cover them in one query rather than one request per kind'),
bbox: z.object({ bbox: z.strictObject({
south: z.number().min(-90).max(90).describe('Southern edge, latitude'), south: z.number().min(-90).max(90).describe('Southern edge, latitude'),
west: z.number().min(-180).max(180).describe('Western edge, longitude'), west: z.number().min(-180).max(180).describe('Western edge, longitude'),
north: z.number().min(-90).max(90).describe('Northern edge, latitude'), north: z.number().min(-90).max(90).describe('Northern edge, latitude'),
+49 -6
View File
@@ -664,6 +664,12 @@ type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; ln
* predates Amap, means `auto`, which keeps Google. * predates Amap, means `auto`, which keeps Google.
*/ */
export const PLACES_PROVIDER_SETTING = 'places_provider'; export const PLACES_PROVIDER_SETTING = 'places_provider';
/**
* The admin switch that hands search and suggestions to Google alone. Off, the
* index and OpenStreetMap answer first and Google is only asked when they find
* nothing, which is what every install has had since 4.3.0.
*/
export const PLACES_GOOGLE_ONLY_SETTING = 'places_google_only';
/** /**
* Whoever holds the keyed slot beside the index for one request: Google's * Whoever holds the keyed slot beside the index for one request: Google's
@@ -761,8 +767,14 @@ export class MapsService {
// ── Controller-facing surface (unchanged signatures) ─────────────────────── // ── Controller-facing surface (unchanged signatures) ───────────────────────
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> { search(
return this.searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>; userId: number,
query: string,
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
provider?: 'google',
): Promise<MapsSearchResult> {
return this.searchPlaces(userId, query, lang, locationBias, { googleOnly: provider === 'google' }) as Promise<MapsSearchResult>;
} }
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias, sessionToken?: string): Promise<MapsAutocompleteResult> { autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias, sessionToken?: string): Promise<MapsAutocompleteResult> {
@@ -1065,6 +1077,33 @@ export class MapsService {
: null; : null;
} }
/**
* Whether this search goes to Google and nowhere else.
*
* Two ways to ask for that, both born of the same moment: the index answered
* a query with something that is not the place the traveller meant, and with
* the index and OpenStreetMap answering first, Google was never consulted as
* long as they found anything at all. The caller can send one search to
* Google (`requested`, the "search Google instead" link under the results),
* and the admin can make that the rule for every search and suggestion (the
* switch beside the key). Either way it only holds when Google holds the key
* slot: on an install without a Google key, or one that picked Amap or
* OpenStreetMap, both change nothing, and the admin panel says so.
*
* Read after the keyed provider on purpose: that lookup already walked the
* key chain, and a setting read ahead of it would shift the order of the
* app_settings reads every test of the chain stubs by position.
*/
private googleOnly(keyed: KeyedProvider | null, requested = false): boolean {
if (keyed?.id !== 'google') return false;
if (requested) return true;
const row = this.database.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?',
PLACES_GOOGLE_ONLY_SETTING,
);
return row?.value === 'true';
}
/** The Amap provider, when Amap holds the keyed slot; null otherwise. */ /** The Amap provider, when Amap holds the keyed slot; null otherwise. */
resolvePlacesProvider(userId: number): AmapPlacesProvider | null { resolvePlacesProvider(userId: number): AmapPlacesProvider | null {
const keyed = this.keyedProvider(userId); const keyed = this.keyedProvider(userId);
@@ -2021,7 +2060,7 @@ export class MapsService {
query: string, query: string,
lang?: string, lang?: string,
locationBias?: { lat: number; lng: number; radius?: number }, locationBias?: { lat: number; lng: number; radius?: number },
opts: { googleIdentityOnly?: boolean } = {}, opts: { googleIdentityOnly?: boolean; googleOnly?: boolean } = {},
): Promise<{ places: Record<string, unknown>[]; source: string }> { ): Promise<{ places: Record<string, unknown>[]; source: string }> {
const keyed = this.keyedProvider(userId); const keyed = this.keyedProvider(userId);
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null }; const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
@@ -2048,7 +2087,10 @@ export class MapsService {
// the same question twice. `null` means it never ran. // the same question twice. `null` means it never ran.
let osmAnswer: Record<string, unknown>[] | null = null; let osmAnswer: Record<string, unknown>[] | null = null;
if (this.trekPlacesEnabled() && !(opts.googleIdentityOnly && apiKey)) { // A search sent to Google on purpose, or the admin's "Google only" switch,
// skips the pair the same way: the search then reads exactly as it did
// before 4.3.0 on an install with a key.
if (this.trekPlacesEnabled() && !(opts.googleIdentityOnly && apiKey) && !this.googleOnly(keyed, opts.googleOnly)) {
// Both at once. The index is a dataset of businesses and is very good // Both at once. The index is a dataset of businesses and is very good
// at those; OpenStreetMap is where the temples, bridges, riverside // at those; OpenStreetMap is where the temples, bridges, riverside
// walks and viewpoints are, and a travel search asks for those // walks and viewpoints are, and a travel search asks for those
@@ -2188,8 +2230,9 @@ export class MapsService {
// could get every instance blocked at once. The index removes that. // could get every instance blocked at once. The index removes that.
// //
// Same contract as search: never throws upward, falls through to what this // Same contract as search: never throws upward, falls through to what this
// method did before. // method did before. The admin's "Google only" switch skips the index here
if (this.trekPlacesEnabled()) { // too, so the suggestions and the search agree on where they come from.
if (this.trekPlacesEnabled() && !this.googleOnly(keyed)) {
try { try {
const centre = locationBias const centre = locationBias
? { ? {
+5
View File
@@ -182,6 +182,11 @@ export class OidcController {
} }
this.oidc.touchLastLogin(result.user.id); this.oidc.touchLastLogin(result.user.id);
// The login row every other method writes (#2417). Here rather than at
// /exchange, because this is where the provider has vouched for the user
// and where the client IP is, the same place the role change is recorded;
// `method` names the way in, as the passkey login does.
this.audit.writeAudit({ userId: result.user.id, action: 'user.login', ip: getClientIp(req), details: { method: 'oidc' } });
// Pass the flag through untouched: `undefined` must reach the token as // Pass the flag through untouched: `undefined` must reach the token as
// "absent", not `false`, or the sliding renewal would later downgrade the // "absent", not `false`, or the sliding renewal would later downgrade the
// default persistent cookie to a browser-session one (remember-me, #1927). // default persistent cookie to a browser-session one (remember-me, #1927).
+1 -1
View File
@@ -511,7 +511,7 @@ export class PackingMcp {
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.', description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
items: z.array(z.object({ items: z.array(z.strictObject({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
category: z.string().optional(), category: z.string().optional(),
quantity: z.number().int().positive().optional(), quantity: z.number().int().positive().optional(),
+4 -3
View File
@@ -306,18 +306,19 @@ export class PlacesMcp {
query: z.string().min(1).max(500).describe('Place name or address to search for'), query: z.string().min(1).max(500).describe('Place name or address to search for'),
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'), locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'), lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored when the instance has no Google key'),
}, },
annotations: TOOL_ANNOTATIONS_READONLY, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'places', mode: 'read' }, access: { group: 'places', mode: 'read' },
}) })
async searchPlace( async searchPlace(
{ query, locationBias, lang }: { { query, locationBias, lang, provider }: {
query: string; locationBias?: { lat: number; lng: number; radius?: number }; lang?: string; query: string; locationBias?: { lat: number; lng: number; radius?: number }; lang?: string; provider?: 'google';
}, },
ctx: McpContext, ctx: McpContext,
) { ) {
try { try {
const result = await this.maps.searchPlaces(ctx.userId, query, lang, locationBias); const result = await this.maps.searchPlaces(ctx.userId, query, lang, locationBias, { googleOnly: provider === 'google' });
return ok(result); return ok(result);
} catch { } catch {
return errorResult('Place search failed.'); return errorResult('Place search failed.');
@@ -35,7 +35,7 @@ export class PluginSearchMcp {
description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.', description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.',
inputSchema: { inputSchema: {
query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'), query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'),
near: z.object({ lat: z.number(), lng: z.number() }) near: z.strictObject({ lat: z.number(), lng: z.number() })
.optional() .optional()
.describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'), .describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'),
lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'), lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'),
@@ -47,7 +47,7 @@ const urlField = reservationUrlSchema.max(2000).optional()
type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number]; type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number];
type BookingType = typeof BOOKING_TYPES[number]; type BookingType = typeof BOOKING_TYPES[number];
const endpointObjectSchema = z.object({ const endpointObjectSchema = z.strictObject({
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'), role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
sequence: z.number().int().min(0).describe('Order within the route (0-based)'), sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'), name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
@@ -520,7 +520,7 @@ export class ReservationsMcp {
description: 'Update the display order of reservations within a day.', description: 'Update the display order of reservations within a day.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
positions: z.array(z.object({ positions: z.array(z.strictObject({
id: z.number().int().positive(), id: z.number().int().positive(),
day_plan_position: z.number().int().min(0), day_plan_position: z.number().int().min(0),
})).describe('Array of { id, day_plan_position } pairs'), })).describe('Array of { id, day_plan_position } pairs'),
@@ -846,6 +846,7 @@ export class ReservationsService {
if (start_day_id && end_day_id) { if (start_day_id && end_day_id) {
this.requireResolvableStay(create_accommodation); this.requireResolvableStay(create_accommodation);
if (resolvedAccId) { if (resolvedAccId) {
const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId);
this.db.run( this.db.run(
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?', 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?',
accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId
@@ -853,7 +854,9 @@ export class ReservationsService {
// The stay just moved. Its stop moves with it, or it is left sitting on a // The stay just moved. Its stop moves with it, or it is left sitting on a
// day nobody sleeps there any more, hidden from the day list because it // day nobody sleeps there any more, hidden from the day list because it
// still carries this booking's id and stranded in the middle of the drive. // still carries this booking's id and stranded in the middle of the drive.
stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in); stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, {
checkInChanged: (check_in || null) !== (prior?.check_in ?? null),
});
} else if (accPlaceId) { } else if (accPlaceId) {
const accResult = this.db.run( const accResult = this.db.run(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
+111 -22
View File
@@ -8,6 +8,8 @@ import { Injectable, HttpException } from '@nestjs/common';
import { type RoadtripPreferences } from '@trek/shared'; import { type RoadtripPreferences } from '@trek/shared';
import { import {
assembleRoadtrip, assembleRoadtrip,
carrierLegsFor,
carrierSeam,
foldRouteRun, foldRouteRun,
splitIntoRuns, splitIntoRuns,
spillChains, spillChains,
@@ -16,12 +18,17 @@ import {
parseAvoid, parseAvoid,
formatDistance, formatDistance,
formatDurationShort, formatDurationShort,
seatCarrierStops,
viasLeaving,
type CarrierBooking,
type CarrierSeam,
type DistanceUnit, type DistanceUnit,
type RoadtripStop, type RoadtripStop,
type PlanDay, type PlanDay,
type RoutedLeg, type RoutedLeg,
type SnappedWaypoint, type SnappedWaypoint,
type RouteAvoidClass, type RouteAvoidClass,
standsAsDay,
} from '@trek/shared/roadtrip'; } from '@trek/shared/roadtrip';
interface StoredDay { interface StoredDay {
@@ -31,13 +38,32 @@ interface StoredDay {
title: string | null; title: string | null;
default_transport_mode: string | null; default_transport_mode: string | null;
} }
/**
* A booking the traveller rides, with what the seam needs of it: the days and clocks at
* both ends, the terminals, and where the day plan seats it. Same projection the client
* reads off the reservation list, so the two plans seat a flight in the same place.
*/
interface CarrierRow {
id: number;
type: string;
title: string;
day_id: number | null;
end_day_id: number | null;
reservation_time: string | null;
reservation_end_time: string | null;
metadata: string | null;
day_plan_position: number | null;
}
interface VisitRow { interface VisitRow {
/** The booking this stop stands for on its check-in day, when there is one. */
stay_id: number | null;
check_in: string | null; check_in: string | null;
/** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */ /** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */
check_out: string | null; check_out: string | null;
checkout_day: number | null; checkout_day: number | null;
id: number; id: number;
day_id: number; day_id: number;
order_index: number;
place_id: number; place_id: number;
name: string; name: string;
lat: number | null; lat: number | null;
@@ -72,10 +98,10 @@ export class RoadtripPlanService {
tripId, tripId,
); );
const visits = this.db.all<VisitRow>( const visits = this.db.all<VisitRow>(
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng, `SELECT a.id, a.day_id, a.order_index, a.place_id, p.name, p.lat, p.lng,
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time, COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
p.duration_minutes, a.end_day, p.duration_minutes, a.end_day,
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id
LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1) LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
LEFT JOIN days checkout ON checkout.id = stay.end_day_id LEFT JOIN days checkout ON checkout.id = stay.end_day_id
@@ -85,6 +111,7 @@ export class RoadtripPlanService {
return { return {
days, days,
visits, visits,
carriers: this.carriers(tripId),
settings: this.preferences.read(tripId), settings: this.preferences.read(tripId),
profiles: this.router.profiles(), profiles: this.router.profiles(),
vias: this.roadtrip.listForTrip(tripId), vias: this.roadtrip.listForTrip(tripId),
@@ -93,6 +120,48 @@ export class RoadtripPlanService {
}; };
} }
/**
* The bookings that seam the drive, a flight, train, ferry, cruise or bus on a day, and
* the hire cars whose desks stand on it, with their terminals and the slots the day
* plan gave them. The reservation service reads the same three tables for the booking
* list; this is the road trip's own cut of them, so the module stays out of the
* reservations graph.
*/
private carriers(tripId: number): CarrierBooking[] {
const rows = this.db.all<CarrierRow>(
`SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position
FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT NULL`,
tripId,
);
if (!rows.length) return [];
const endpoints = this.db.all<{
reservation_id: number;
role: string;
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
}>(
`SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`,
tripId,
);
const positions = this.db.all<{ reservation_id: number; day_id: number; position: number }>(
`SELECT p.reservation_id, p.day_id, p.position
FROM reservation_day_positions p JOIN reservations r ON r.id = p.reservation_id WHERE r.trip_id = ?`,
tripId,
);
return rows.map((row) => ({
...row,
endpoints: endpoints.filter((e) => e.reservation_id === row.id),
day_positions: Object.fromEntries(
positions.filter((p) => p.reservation_id === row.id).map((p) => [String(p.day_id), p.position]),
),
}));
}
async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) { async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) {
const context = this.context(tripId, userId); const context = this.context(tripId, userId);
const preferences = { ...context.settings, ...overrides }; const preferences = { ...context.settings, ...overrides };
@@ -103,14 +172,12 @@ export class RoadtripPlanService {
); );
if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window) if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window)
throw new HttpException({ error: 'Day end must be later than day start.' }, 400); throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
const plan: PlanDay[] = context.days.map((day) => ({ const seams = context.carriers.map((booking) => carrierSeam(booking)).filter((seam): seam is CarrierSeam => seam !== null);
dayId: day.id, const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
dayNumber: day.day_number, const plan: PlanDay[] = context.days.map((day) => {
date: day.date, const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
title: day.title, const stops = visits.map(
stops: context.visits (v, index): RoadtripStop => ({
.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null)
.map((v, index) => ({
assignmentId: v.id, assignmentId: v.id,
ownerDayId: day.id, ownerDayId: day.id,
ownerIndex: index, ownerIndex: index,
@@ -123,14 +190,29 @@ export class RoadtripPlanService {
// planner makes in the browser (useRoadtripRoutes). // planner makes in the browser (useRoadtripRoutes).
leaveAt: v.end_time, leaveAt: v.end_time,
checkInTime: v.check_in, checkInTime: v.check_in,
night: v.stay_id !== null,
dwellMinutes: v.duration_minutes, dwellMinutes: v.duration_minutes,
endDay: v.end_day === 1, endDay: v.end_day === 1,
legMode: v.leg_transport_mode, legMode: v.leg_transport_mode,
incomingLegMode: v.incoming_leg_transport_mode, incomingLegMode: v.incoming_leg_transport_mode,
stopType: v.stop_type, stopType: v.stop_type,
fillPercent: v.fill_percent, fillPercent: v.fill_percent,
})), }),
})); );
return {
dayId: day.id,
dayNumber: day.day_number,
date: day.date,
title: day.title,
// The terminals of the day's rides, seated where the day plan shows the booking.
stops: seatCarrierStops(
day.id,
stops,
visits.map((v) => v.order_index),
seams,
),
};
});
const allLegs: Record<string, RoutedLeg> = {}; const allLegs: Record<string, RoutedLeg> = {};
const snapByDay: Record<number, Record<string, SnappedWaypoint>> = {}; const snapByDay: Record<number, Record<string, SnappedWaypoint>> = {};
const missedByDay: Record<number, RouteAvoidClass[]> = {}; const missedByDay: Record<number, RouteAvoidClass[]> = {};
@@ -143,21 +225,28 @@ export class RoadtripPlanService {
const distanceUnit: DistanceUnit = const distanceUnit: DistanceUnit =
this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric'; this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric';
const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => { const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => {
const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
// A ride is no road: the leg between its terminals is the booking's minutes, and
// the router is never asked for it.
const rides = carrierLegsFor(
stops,
profile,
(from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId),
(from, to) => stopKey(from) + '>' + stopKey(to),
);
if (rides) {
Object.assign(allLegs, rides);
return;
}
const points: { lat: number; lng: number }[] = []; const points: { lat: number; lng: number }[] = [];
const stopAt: number[] = []; const stopAt: number[] = [];
stops.forEach((stop, index) => { stops.forEach((stop, index) => {
stopAt.push(points.length); stopAt.push(points.length);
points.push({ lat: stop.lat, lng: stop.lng }); points.push({ lat: stop.lat, lng: stop.lng });
if (index < stops.length - 1) if (index < stops.length - 1)
points.push( points.push(...viasLeaving(stop, context.vias).map((v) => ({ lat: v.lat, lng: v.lng })));
...context.vias
.filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
.map((v) => ({ lat: v.lat, lng: v.lng })),
);
}); });
const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
try { try {
if (points.length > 100) throw new Error('Too many waypoints'); if (points.length > 100) throw new Error('Too many waypoints');
const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid); const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid);
@@ -245,8 +334,8 @@ export class RoadtripPlanService {
preferences.roadtrip_range_km, preferences.roadtrip_range_km,
) || null; ) || null;
const calculated = assembleRoadtrip({ const calculated = assembleRoadtrip({
plan: plan.filter((d) => d.stops.length > 1), plan: plan.filter((d) => standsAsDay(d.stops)),
quietDays: plan.filter((d) => d.stops.length < 2), quietDays: plan.filter((d) => !standsAsDay(d.stops)),
window, window,
allLegs, allLegs,
snapByDay, snapByDay,
@@ -28,7 +28,7 @@ export class RoadtripPlanningMcp {
@Tool({ @Tool({
name: 'get_roadtrip_context', name: 'get_roadtrip_context',
description: description:
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.', 'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
inputSchema: { tripId: z.number().int().positive() }, inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' }, access: { group: 'trips', mode: 'read' },
@@ -41,7 +41,7 @@ export class RoadtripPlanningMcp {
@Tool({ @Tool({
name: 'calculate_roadtrip', name: 'calculate_roadtrip',
description: description:
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.', 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
inputSchema: roadtripPlanRequestSchema.shape, inputSchema: roadtripPlanRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' }, access: { group: 'trips', mode: 'read' },
+3 -3
View File
@@ -89,14 +89,14 @@ export class RoadtripMcp {
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
vias: z.array(z.object({ vias: z.array(z.strictObject({
after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'), after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'),
lat: z.number().min(-90).max(90), lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180), lng: z.number().min(-180).max(180),
})).max(100).describe('In the order the drive passes through them'), })).max(100).describe('In the order the drive passes through them'),
replace_legs: z.array(z.number().int().min(0)).max(100).optional() replace_legs: z.array(z.number().int().min(0)).max(100).optional()
.describe('Legs to clear before inserting, by the index of the stop they follow'), .describe('Legs to clear before inserting, by the index of the stop they follow'),
track: z.object({ track: z.strictObject({
place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'), place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'),
stray_km: z.number().min(0).max(40_000).nullable().optional() stray_km: z.number().min(0).max(40_000).nullable().optional()
.describe('How far the fitted route still runs from the track at its worst point'), .describe('How far the fitted route still runs from the track at its worst point'),
@@ -139,7 +139,7 @@ export class RoadtripMcp {
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
vias: z.array(z.object({ vias: z.array(z.strictObject({
id: z.number().int().positive(), id: z.number().int().positive(),
after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'), after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
})).max(500), })).max(500),
+1 -1
View File
@@ -88,7 +88,7 @@ export class TripsMcp {
description: z.string().max(2000).optional().describe('Trip description'), description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'), end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'), currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD). Left out, the trip takes the display currency from the settings (see get_display_settings), or EUR when none is set.'),
day_count: z.number().int().min(1).max(MAX_TRIP_DAYS).optional().describe( day_count: z.number().int().min(1).max(MAX_TRIP_DAYS).optional().describe(
'How many days a trip without dates gets (default 7). Ignored when start_date and end_date are both set, because the range decides the count.'), 'How many days a trip without dates gets (default 7). Ignored when start_date and end_date are both set, because the range decides the count.'),
reminder_days: z.number().int().min(0).max(30).optional().describe( reminder_days: z.number().int().min(0).max(30).optional().describe(
+2 -1
View File
@@ -32,6 +32,7 @@ import { StorageModule } from '../storage/storage.module';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { buildStorageUploadOptions } from '../storage/storage-upload.factory'; import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller'; import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
import { SettingsModule } from '../settings/settings.module';
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
* not capture the nested sub-domain mounts (collab, files, ...). */ * not capture the nested sub-domain mounts (collab, files, ...). */
@@ -48,7 +49,7 @@ import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
}), }),
}), }),
StorageModule, StorageModule,
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule], McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule, SettingsModule],
controllers: [TripsController], controllers: [TripsController],
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc], providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
+19 -5
View File
@@ -11,6 +11,7 @@ import { ReservationsService } from '../reservations/reservations.service';
import { VacayService } from '../vacay/vacay.service'; import { VacayService } from '../vacay/vacay.service';
import { UnsplashService } from '../unsplash/unsplash.service'; import { UnsplashService } from '../unsplash/unsplash.service';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { SettingsService } from '../settings/settings.service';
import { NotFoundError, ValidationError } from '../common/domain-errors'; import { NotFoundError, ValidationError } from '../common/domain-errors';
export const MS_PER_DAY = 86400000; export const MS_PER_DAY = 86400000;
@@ -134,15 +135,16 @@ export interface GuestMember {
/** /**
* Trip aggregate root, DI-native. Membership, the calendar export and the two * Trip aggregate root, DI-native. Membership, the calendar export and the two
* read aggregates live in their own domains now; what is left is the write core * read aggregates live in their own domains now; what is left is the write core
* plus day generation, which is why the constructor is eight parameters instead * plus day generation, which is why the constructor is far shorter than the
* of fourteen. The SQL moved 1:1 from the legacy * fourteen parameters it started with. The SQL moved 1:1 from the legacy
* services/tripService.ts: identical statements, the `||` falsy-coercion * services/tripService.ts: identical statements, the `||` falsy-coercion
* defaults, the post-write TRIP_SELECT re-selects and the mixed * defaults, the post-write TRIP_SELECT re-selects and the mixed
* named/positional parameter styles are all preserved byte-for-byte. * named/positional parameter styles are all preserved byte-for-byte.
* Post-migration quirk fixes on top of the 1:1 move: the multi-statement * Post-migration quirk fixes on top of the 1:1 move: the multi-statement
* deletes (remove, deleteGuest's re-split + user delete) run in * deletes (remove, deleteGuest's re-split + user delete) run in
* db.transaction(), and listMembers' owner row COALESCEs display_name like * db.transaction(), listMembers' owner row COALESCEs display_name like
* the member rows. Auth (canAccessTrip), per-field permission checks and * the member rows, and create() defaults the currency to the owner's display
* currency instead of the legacy EUR literal. Auth (canAccessTrip), per-field permission checks and
* audit logging stay in the controller (1:1 with the legacy route); * audit logging stay in the controller (1:1 with the legacy route);
* trip:updated / trip:deleted broadcasts stay in the controller too — this * trip:updated / trip:deleted broadcasts stay in the controller too — this
* service emits none. * service emits none.
@@ -159,12 +161,24 @@ export class TripsService {
private readonly realtime: RealtimeService, private readonly realtime: RealtimeService,
private readonly unsplash: UnsplashService, private readonly unsplash: UnsplashService,
private readonly storage: StorageService, private readonly storage: StorageService,
private readonly settings: SettingsService,
) {} ) {}
private get db() { private get db() {
return this.dbs.connection; return this.dbs.connection;
} }
/**
* The currency a trip gets when the caller names none: the person's display
* currency, admin default merged in, else EUR. The trip dialog pre-fills the
* same value on the client, so a trip created through MCP or a plugin lands
* in the currency the person reads amounts in instead of always in EUR.
*/
private defaultCurrencyFor(userId: number): string {
const preferred = this.settings.getUserSettings(userId).default_currency;
return typeof preferred === 'string' && preferred.trim() ? preferred.trim() : 'EUR';
}
canAccessTrip(tripId: string | number, userId: number) { canAccessTrip(tripId: string | number, userId: number) {
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
} }
@@ -328,7 +342,7 @@ export class TripsService {
const result = this.db.prepare(` const result = this.db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days) INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd); `).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || this.defaultCurrencyFor(userId), rd);
const tripId = result.lastInsertRowid; const tripId = result.lastInsertRowid;
this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count); this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count);
+14
View File
@@ -101,6 +101,9 @@ const { db } = vi.hoisted(() => {
// StorageRegistryService (behind StorageModule, now in this module chain) reads // StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit. // this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
// A trip created without a currency takes the owner's display currency, read
// off the per-user settings rows.
tmp.exec('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));');
return { db: tmp }; return { db: tmp };
}); });
@@ -160,6 +163,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
db.prepare('DELETE FROM trip_members').run(); db.prepare('DELETE FROM trip_members').run();
db.prepare('DELETE FROM days').run(); db.prepare('DELETE FROM days').run();
db.prepare('DELETE FROM audit_log').run(); db.prepare('DELETE FROM audit_log').run();
db.prepare('DELETE FROM settings').run();
canAccessTrip.mockReturnValue({ user_id: 1 }); canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true); checkPermission.mockReturnValue(true);
}); });
@@ -198,6 +202,16 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
expect(forbidden.status).toBe(403); expect(forbidden.status).toBe(403);
}); });
it('201 create without a currency takes the display currency from the settings', async () => {
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'default_currency', ?)").run(JSON.stringify('USD'));
const preferred = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Road trip' });
expect(preferred.status).toBe(201);
expect(preferred.body.trip).toMatchObject({ title: 'Road trip', currency: 'USD' });
const explicit = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Tokyo', currency: 'JPY' });
expect(explicit.status).toBe(201);
expect(explicit.body.trip).toMatchObject({ title: 'Tokyo', currency: 'JPY' });
});
it('201 create keeps every day of a trip longer than a year (#2403)', async () => { it('201 create keeps every day of a trip longer than a year (#2403)', async () => {
// 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365. // 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365.
const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)) const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
@@ -186,6 +186,7 @@ export function createMcpTestRegistry(): McpRegistry {
realtimeService, realtimeService,
new UnsplashService(dbService, new RuntimeEnvService(), generalStorage), new UnsplashService(dbService, new RuntimeEnvService(), generalStorage),
generalStorage, generalStorage,
new SettingsService(dbService),
); );
const readModelService = new TripReadModelService( const readModelService = new TripReadModelService(
dbService, membersService, daysService, accommodationsService, budgetService, dbService, membersService, daysService, accommodationsService, budgetService,
+1 -1
View File
@@ -113,7 +113,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations); const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations);
// After accommodations: a hotel booking writes the stay's day stop through it. // After accommodations: a hotel booking writes the stay's day stop through it.
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations); const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations);
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage); const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, new SettingsService(dbs));
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub()); const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub());
const guards = new PluginGuards(dbs, permissions, addons); const guards = new PluginGuards(dbs, permissions, addons);
+21
View File
@@ -190,6 +190,27 @@ describe('GET /api/auth/oidc/callback', () => {
expect(res.headers.location).toContain('/login?oidc_code='); expect(res.headers.location).toContain('/login?oidc_code=');
}); });
it('OIDC-004b: a successful login writes the user.login row every other method writes (#2417)', async () => {
const { user } = createUser(testDb, { email: 'audited@example.com' });
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'test-access-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-audited-1' } });
mockGetUserInfo.mockResolvedValueOnce({ sub: 'sub-audited-1', email: 'audited@example.com', name: 'Audited', email_verified: true });
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
expect(res.status).toBe(302);
// The query from the report, against the same table.
const rows = testDb.prepare('SELECT user_id, action, details FROM audit_log WHERE user_id = ? ORDER BY id DESC').all(user.id) as
{ user_id: number; action: string; details: string | null }[];
expect(rows.map(r => r.action)).toContain('user.login');
const login = rows.find(r => r.action === 'user.login')!;
expect(JSON.parse(login.details || '{}')).toEqual({ method: 'oidc' });
const counted = testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number };
expect(counted.login_count).toBe(1);
});
it('OIDC-005: new user gets created when registration is open', async () => { it('OIDC-005: new user gets created when registration is open', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC); mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 }); mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 });

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