Compare commits

..

83 Commits

Author SHA1 Message Date
github-actions[bot] 1c05ee4b3a chore: bump version to 4.3.3 [skip ci] 2026-09-26 12:23:05 +00:00
Maurice 1ff22490f2 Merge pull request #2529 from liketrek/fix/v4.3.3
v4.3.3 bug fixes
2026-09-26 14:21:56 +02:00
Maurice aa1f76ad11 fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:26 +02:00
Maurice c6a089107f chore(notices): the release notice points at 4.3.3 2026-09-26 11:22:25 +02:00
Maurice cd06f0f7f4 fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).

The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.

Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.

StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.

The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.

The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
2026-09-26 11:22:19 +02:00
gso e6f66014a5 fix(planner): transport edit window can delete the booking
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.

Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
2026-09-26 10:36:01 +02:00
Maurice 06bee95ae3 fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 04:22:50 +00:00
Maurice 5f77c2cee9 fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.

The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
2026-09-26 04:22:50 +00:00
Maurice cf5920ebe0 fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.

A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.

The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.

Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.

The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.
2026-09-26 04:22:50 +00:00
Maurice a0952bd871 fix(mobile): show the drive from a flight's arrival airport in the plan timeline (#2502)
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.

buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
2026-09-26 04:22:50 +00:00
Maurice 3c7f6e69dd fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
2026-09-26 04:22:50 +00:00
Maurice 30a3f664b1 fix(map): open a place from its pin after the map was dragged (#2504)
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.

Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.

The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
2026-09-26 04:22:50 +00:00
Maurice 0a3a993dfc fix(share): only call a share link expired when the server says so (#2505)
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.

The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.

Both screens use the theme tokens instead of hex literals. New keys are
translated in all 23 locales.
2026-09-26 04:22:50 +00:00
Maurice bea3af4b45 fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.
2026-09-26 04:22:50 +00:00
Maurice 26ab4a58b1 fix(db): keep SQLite temp files in memory so trips can be deleted (#2518)
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.

Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
2026-09-26 02:26:39 +00:00
Maurice 0887f251ce fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 02:26:39 +00:00
github-actions[bot] 151e32107f chore: bump version to 4.3.2 [skip ci] 2026-09-24 11:14:02 +00:00
Maurice 8779529efb feat(roadtrip): the corridor asks in one line, and a narrow rail stops counting stops
Along the route: once kinds are picked, the closed "Looking for" control shows them by
their coloured icons alone, and names them in its tooltip and to a screen reader; the
names stay in the list. The day the search runs along moved out of the header into the
same line, as a dropdown of the same shape, so the question reads as one: these kinds,
along this day. Both share one small module for the shape and for closing on a click
outside or Escape.

Pulled narrow, the road trip rail drops the stop count from its head, with the hairline
beside it, and from every day header, and keeps distance and time.
2026-09-24 11:32:52 +02:00
Maurice e1315806c2 feat(roadtrip): the ride block and the stay rows read as badges
The flight block in the road trip rail printed each fact two or three times: the booking
title in capitals with its duration cut off, the airport code three times per side, an
unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning
"+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed.

It now reads in four lines: the booking as typed; the two codes with their timetable clocks
and the ride between them; the places without the code in brackets; and the check-in as a
badge that carries its own state, the hour when the drive is on time, by how much when it
is late for the check-in, and "Missed, there at 23:54" when it gets there after the
departure, with the duration beside it. A missed ride edges the block and says so in the
day header, also when the day is folded. Trains and buses say boarding. The phone shows
the same, and its rows now read the lateness and missed-leave findings at all.

The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out
with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A
plan that leaves after the check-out turns that badge instead of adding a line. They wear
the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the
driving settings button shows when the stays are switched on.

The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the
airport code is stripped by one shared helper instead of two private copies.
2026-09-24 11:32:52 +02:00
Maurice e64ade010f fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated
by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight
out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the
flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a
stay now waits behind the landing when it lies nearer the arrival than the departure, and
its check-in only holds it when the ride lands before the room is ready. A time typed on the
stop keeps its seat.

With the stays at both ends of a day switched on:
- a day with nothing but the next stay's check-in and a ride booked across it gets no drive
  from one hotel to the other, the same rule the day plan follows since #2476;
- a stay is never joined by road to a terminal more than 2000 km away. The check moved to
  shared/roadtrip so Days and the road trip read one copy.

A departure terminal's pin is dated: reaching it more than half a day late no longer moves
the flight to the next day without a word, it is a late warning with the full minutes.
A check-in evening also carries the stay's check-in time, as a label only.

calculate_roadtrip runs the same code and its description says all of the above.
2026-09-24 11:32:52 +02:00
Maurice 3062df153f chore(notices): the release notice points at 4.3.2 2026-09-24 11:27:12 +02:00
Maurice 233076030b fix(docsync): a store picked before the panel settles stays picked
The effect that keeps the selection on a bound store read the pick from
its own render. When a click landed after the card was committed but
before that effect ran, the effect then wrote the first store back over
the click. On a loaded CI runner this is what made FE-DOCSYNC-PANEL-008
fail now and then. The effect now reads the current pick when the update
runs and only follows the list.

FE-DOCSYNC-PANEL-023 forces that order and fails on the old effect.
2026-09-24 08:14:29 +02:00
Maurice c5ac479915 fix(dayplan): sort the trip's dates with an explicit compare
ISO dates sort the same as plain strings, but a sort without a compare
function reads as an accident to anyone checking it, and SonarCloud flags
it as one. The three places that order the trip's dates now say how.
2026-09-23 23:11:10 +02:00
Maurice 954758082b fix(dayplan): calmer day dialogs, and the shrink question side by side
The day dialog hangs from a fixed top edge, so switching to the delete
question only moves its bottom edge, and the question opens at its top
with the focus on Cancel. Cancel, Close and the quiet add share one
outline; the dated add is drawn in the accent instead of filled with it.
Dates in the dialog, its hints and the new-day toast read the way the day
list writes them, and captions use the muted text colour for contrast. A
stay booked twice is named once in the warning. On the phone the dated
pill shows only its date, so both pills fit side by side.

When the trip dialog asks both how bookings follow a moved start and
which days the new dates remove, the two now stand side by side in a wide
dialog instead of one long column.
2026-09-23 22:40:47 +02:00
Maurice 83be759c49 fix(dayplan): the delete question asks inside the day dialog, and its buttons stay small
Deleting a day no longer stacks a second dialog over the reorder dialog. The
dialog swaps its list for the question at the same size and in the same place;
Cancel and Escape bring the list back with the focus on the row asked about.
The foot is one row of normal sized buttons with the hint line above it, the
undated day is labelled "Without date" in every language, and the dialog is a
step wider so no label breaks. The list of what goes with a day is quieter: a
cancelled stay tints its whole row, a day with nothing on it is one plain line.
On the phone the add buttons are two pills under the scrolling list, and the
confirm sheet moves a long label to its own row instead of breaking it.
2026-09-23 22:40:47 +02:00
Maurice f3ca0b1a4c test(mobile): the delete-day confirm test takes a free id
FE-MOB-SHOST-030 is taken by the invite field test that landed on dev in
the meantime, so the delete-day confirm test moves to 032.
2026-09-23 22:40:47 +02:00
Maurice 302101b186 fix(dayplan): the delete question names every date and night it changes
The question before a day goes now also says which day without a date takes over the last date, which stay running across the day loses a night and when it checks out, and what the cancelled booking costs, or that it has no expense at all; further bookings on the same stay are counted. Road trip boundaries follow their own days when the numbering still has a hole from the old delete, and ones left on no day are dropped.

A long list scrolls inside the confirm card on both shells with the buttons always in reach, Escape closes only the sheet on top and takes the trip dialog back from its review step to the form, and the panel of a deleted day closes, also when a fellow traveller deletes it. Undo steps on a deleted day are dropped, a restored place skips days that are gone, and an undo that fails says so. The day cache is written without holding up the answer once the server has it, the tip under a new start date points at the dated add button, and two Collab tests find the confirm button by its role instead of an old colour class.
2026-09-23 22:40:47 +02:00
Maurice 1639fc6a3e docs(wiki): deleting and adding days, and what shortening a trip removes
The day plan page now walks through the reorder dialog and its phone sheet as they are: the two ways to add a day on a trip with dates, the question before a day is deleted and what it lists, and the step the trip dialog puts before a save that removes days. Accommodations explains why a stay behaves differently when its day is deleted and when the trip is shortened, and the trip dialog, undo, public API, MCP and plugin pages point at the new rules, including the dated option of create_day and days.create, removed_days on update_trip and the last day that cannot be deleted.
2026-09-23 22:40:46 +02:00
Maurice 1b63285ad0 feat(trips): shortening a trip says first which days go and what is on them
The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.

The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.

Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
2026-09-23 22:40:46 +02:00
Maurice b56f335978 feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
2026-09-23 22:40:46 +02:00
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00
Maurice 436f0740fb fix(planner): the delete question says plainly what goes with a booked place
"The night booked at "Rostock" goes with the place, together with the
booking "Rostock" and any expense linked to it" named the place twice and
read awkwardly. The note now says "This also deletes the stay booked at
"Rostock", its booking and any expense linked to it", quotes the booking
only when its title differs from the place, and reads the same in all 23
languages.
2026-09-23 22:39:54 +02:00
Maurice 1423ba8933 fix(places): follow-ups from the review of the schemeless website fix
create_place_accommodation refused the whole booking again once its website
went through the place contract, for '' and free text as well, which it always
took. It keeps its old input schema now and leaves a website off when the
contract refuses it, so a bare host still gains https and a script link is
still not stored.

normalizePlaceWebsite keeps an http or https value whenever a browser opens it,
the way the write contract does, so a plugin or source answering with an
intranet host, an IPv6 literal or a trailing dot keeps its website. The dot
check only tells a bare host from a word. A details row cached before the fix
(a week for Google and Amap, until a refresh for an expanded one) hands its
website out through the same helper.

Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014,
MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079,
PLUGIN-SEARCH-2483-02.
2026-09-23 22:35:01 +02:00
Maurice 2350c3ae14 fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
2026-09-23 22:35:01 +02:00
Maurice 362b1ee8d1 fix(map): a selected stay is flown to even when no pin stands for it
With the Unplanned filter on, a click on a stop in the day list moved the
map while a click on the stay chip did nothing: the map only went to a
selected place it showed as a pin, and the GL map also to one of the open
day's stops, which a stay is neither. Both renderers now look the target
up the same way, the pin first, then the day's stop, then the selected
place itself, so the map goes to the stay whatever filter hides its pin.
2026-09-23 22:11:51 +02:00
Maurice b5457d9e6a fix(planner): a booked hotel stays in the place list when road trip stops are hidden in Days
Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel'
is one of the road trip stop types. With "Show in Days too" switched off,
Days dropped every such place from the trip's place list, so a hotel
vanished from the sidebar, from the booking's own place picker and from
the Days map the moment it was booked, and a click on it in the day list
found nothing. Nothing was deleted: road trip mode kept showing it.

Days now leaves out only road trip stops nobody booked. A lodging place
stays, and so does any place a stay or a booking points at, on the
desktop and on the phone. Trips planned on 4.3.1 show their hotels again
without any change to their data.

An imported GPX or KML track is no longer offered as the place of a stay,
in the booking form, the day's hotel picker and the phone's sheets. A
stay that already names a track keeps showing it.
2026-09-23 21:28:34 +02:00
Maurice a1d6348c5f revert(collections): a saved place carries no price, website or phone again
Takes back the editable price, currency, website and phone on collection
places from 0df82185b and its review follow-up in a910c2a83. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698bdb, which
only mattered for the price field.
2026-09-23 20:32:41 +02:00
Maurice f16698bdb1 fix: an Immich error leaves out Node's advice, a locked save looks locked
A connection test against a server with a self-signed certificate ended in
"fetch failed (self-signed certificate; if the root CA is installed
locally, try running Node.js with --use-system-ca)". The advice is meant
for whoever runs the server and cannot be acted on from the settings card,
so it is cut off.

The save button of a collection place stays locked while the price cannot
be read, but looked and pointed like an active one. It is dimmed and shows
the not-allowed cursor now.
2026-09-23 18:03:58 +02:00
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00
Maurice 0df82185bd feat(collections): a saved place keeps its price, website and phone, editable like in a trip
A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).

- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
  nullable), currency (three letters, trimmed and upper-cased), website
  (http or https, as on save) and phone (trimmed, capped at 60 like the file
  import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
  transaction. update_collection_place spreads the same shape, so REST and
  MCP take and refuse the same values; its description now names the price
  and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
  currency picker, website and phone, and show them in read mode (price only
  above zero, phone as a call link, website as a link chip through
  safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
  sends only the fields the user changed and always pairs a price with its
  currency, since a list has no base currency. An amount that does not parse
  marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
  all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.

Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
2026-09-23 17:41:26 +02:00
Maurice e8ec3427e5 fix(share): the user picked to invite shows in the field before it is sent
The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.

CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.

Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.

Refs #2478
2026-09-23 17:41:26 +02:00
Maurice 8d78c988c3 fix(dayplan): a moving day with a flight draws no drive from one stay to the next
A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.

- The fallback drive between the two hotels is no longer drawn when any
  such booking is on the day, located or not. A day without a booking
  keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
  reaches the hotel, not the first, so an earlier drive to the same spot
  no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
  describe that same drive, return nothing, and on the desktop and the
  phone the two buttons are left out whenever fewer than two stops
  remain, instead of doing nothing on a tap.

A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
2026-09-23 17:41:25 +02:00
Maurice ae80ad266d fix(immich): a server with a self-signed certificate can be connected, as the other providers can
Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
2026-09-23 17:41:25 +02:00
Maurice 41b14ebc2a fix(import): a booking the AI reads without its venue is asked for one, and a failed import does not look like a success
Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.

The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.

On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.

The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.

The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
2026-09-23 17:41:25 +02:00
Maurice 05f533dd0d fix(roadtrip): a late check-out morning says so, a road driven twice takes its via
The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
2026-09-23 16:01:40 +02:00
Maurice e6e520b78f fix(roadtrip): the hotel at the edge of a day stands, drives and reads right
A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.

On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
2026-09-23 15:31:09 +02:00
Maurice 05651eed1a docs(wiki): a road trip day can start and end at the stay
The road trip page gains a section on the new switch under the route line:
off by default, shared by the trip, and on the phone the one control in the
driving figures. It says how the hotel rows read, what they count towards,
what a click opens, when a side of the day gets none, and that the drive to
and from the stay takes no via and no other way. The daily travel times, the
settings table, the phone section, the MCP table and troubleshooting point to
it.

The accommodation and planner overview pages name the switch and that it
leaves Days alone, and the MCP tool page describes the bookend stops of
calculate_roadtrip, the stays of get_roadtrip_context, the corridor and the
ignored boundary over a booked night.
2026-09-23 15:31:09 +02:00
Maurice 7e7d13c021 feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.

The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
2026-09-23 15:31:09 +02:00
Maurice 514e2e62ee feat(roadtrip): a booked night can stand at both ends of its days
The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.

The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.

The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
2026-09-23 15:31:09 +02:00
Maurice 7fa0434570 refactor(planner): the night's hotels are read in shared
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
2026-09-23 15:31:09 +02:00
Maurice 3a67a8e279 fix(planner): trip places in every manual location field, never at 0,0
The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.

The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
2026-09-23 13:41:31 +02:00
hiro-nikaitou cc5957dc6a fix(planner): offer trip places in manual transport
Signed-off-by: hiro-nikaitou <vieteviete@proton.me>
2026-09-23 13:34:02 +02:00
Maurice 83d088e842 fix(roadtrip): a merged drive keeps its first piece ends when a leg comes without them
A leg a routing provider answers without its from and to points threw in
the merge, and the day it belonged to never finished routing. Such a leg now
keeps the first piece's ends; the texts are still written from the sums.
2026-09-23 12:53:24 +02:00
Maurice e921799580 test(roadtrip): mount the phone case of a refused way the way the phone suites do
The desk helper waits for road trip mode, which never turns on at phone
width, so the case timed out before it asked anything. It now waits for the
drive tab, as the phone feed suites already do.
2026-09-23 12:30:10 +02:00
Maurice 34c1dc79b5 fix(roadtrip): a drive bent through a via prints its whole length, and a refusal is said once
A via cuts a drive into one routed leg per waypoint pair. Folding them back
onto the stop pair summed the metres and seconds but kept the first piece's
printed texts and end point, and the phone reads the texts first: a drive
into the next day of 3.9 mi in 14 min showed 1.9 mi in 6 min. Every choice
under "Other ways" writes vias, so every choice ran into it. The pieces are
now merged by one shared helper that writes the texts from the sums in the
reader's unit, for a day's own run, for the drive between two days and in
the server's plan; the drive between two days is asked for again when the
unit changes, as a day's run already is.

On the desk a refused way was said in the bar's status line and again in a
toast laid over that line. The desk keeps the status line; the phone, whose
bar has no room for the sentence, keeps the toast.
2026-09-23 12:03:00 +02:00
Maurice 451e6d240b fix(roadtrip): seat rides and the drive into tomorrow by one rule, and say why a way was not taken
The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.

A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
2026-09-23 11:28:24 +02:00
Maurice ab3a81a9eb fix(roadtrip): a booked ferry is driven to, and one without a date says so
A ferry between two stops without a clock was seated by the clock alone, which files a
ride with nothing timed before it at the end of the day. The desktop day list stored that
slot, and the road trip drove overland to the far shore and back to the pier before the
crossing. A ride that leaves and lands on the same day, with both terminals located, now
takes the seat between the clocks that adds the fewest straight-line kilometres. One rule
in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists
and the road trip in the browser and on the server. Slots already stored are left alone.

A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the
map still drew its arc. The rail and the phone tab now name such a booking with a way to
open it, and calculate_roadtrip lists it under undatedRides.
2026-09-23 11:28:24 +02:00
Maurice e6daed9403 fix(roadtrip): the drive between two days can take another way too
The drive from where one day ended to where the next begins was the one stretch of a
connected road trip that offered no other ways. The band above a card's first stop had no
button on the desk, and the phone did not show that drive at all. A via behind a day's last
stop, where the map already files a point dropped on that drive, was deleted when the day
was reordered under Days, and was left on no stop when that last stop was removed or
dragged up the day. Switching the avoided road classes or the day's profile kept the old
road and minutes on it until a reload.

The drive in now opens the same picker as any leg, on the desk band and on a new row at
the head of the phone's chain that names where it leaves. A choice is proven with the
seam's own router and written behind the last stop of the day before, also when a day with
a single stop lies between. The rule for that via lives once in shared (carriedSeam): it
stays with the last stop while that stop stays last, and is read like any other once it is
not, for the planner's drags and removals as for the server's sorts and seated nights. The
cached answer for a seam now records its mode and avoided classes, so changing either asks
for it again.
2026-09-23 11:28:24 +02:00
Maurice 53a422d22d fix(roadtrip): other ways come from the router that draws the leg, and a choice is saved once it holds
The picker of other ways asked OSRM with nothing avoided while the rail drives a trip
that avoids a road class through Valhalla, so the list had no current road, called a road
the rail was not on the fastest, and choosing it changed nothing. A choice was stored as
one via where the offer strayed furthest, without asking whether the router then drove
it: a point on a ferry was pulled to the pier and the drive went overland, and a way
weighed away from motorways kept the motorway after its one point. An OSRM answer standing
in for a Valhalla that did not respond was cached as the avoided road, unmarked.

The rail now hands out a router per leg with the leg's own mode, avoided classes and
engine, and its days carry the line of every leg. Offers come from that engine, and the
rail's own leg always heads the list as Current. A choice is pinned and proven against
that router with at most three pins before anything is written; a way it will not follow
is not saved and says why, with a hint when it crosses by ferry, and a leg that changed
meanwhile or a device offline writes nothing. The OSRM stand-in is filed under the plain
key only and flags the day as not avoided.
2026-09-23 11:28:24 +02:00
Maurice 6124e0b056 fix(maps): amap hours are read whole or not at all, and the tip stash lives on the service
The translated hours were taken as soon as any segment parsed. A day the
parser skipped, such as a weekend written 周六、周日, then had no period, and the
client reads a day without a period as closed, so a shop open on Saturday
showed Closed there. Text that did not parse went out as a one-line list,
which the client reads as Monday and splits at the first colon, and which
kept the enrichment from falling back to OpenStreetMap's hours.

amapOpeningToOsm now also reads Amap's documented form (a colon after the
days, remarks in brackets with their own semicolons), 每天, 周末, 工作日, 星期,
到, 、, full-width colons, tilde ranges, 次日, 全天 and 24小时, and a day named
only as closed. It returns null unless every weekly segment reads, and no
hours are sent then.

The autocomplete tips move from a module-level map into AmapTipStash, one
instance on MapsService: a provider is built per request, and the pick and
its details lookup are two requests. Only the tips actually served are kept,
and a stashed tip without a coordinate answers null, so the client falls back
to its text search instead of receiving a place it cannot pin.
2026-09-23 09:36:04 +02:00
pipipiper d52154c765 fix(maps): amap answers for tip-only places, region-bearing fallback queries, and Chinese hours 2026-09-23 09:36:04 +02:00
Maurice aceb75949b fix(planner): closing the place dialog resets the whole search block
The planner keeps the place dialog mounted while it is closed, so its search
block survived a closing. Clearing the field on close (#2465) left the result
list under the empty field, together with the "Search Google instead" line
offering that list's query again, and a pick from that list wrote its name,
address and coordinates over whichever place the dialog was opened for next.

Closing now drops the list, the suggestions, the Google line's query and the
billing session, and cancels a pending autocomplete. A full search or a
suggestion lookup that answers after the dialog closed is discarded instead
of filling the next opening, and no longer ends the session a new opening may
have started.
2026-09-23 09:11:48 +02:00
Luffy 4f5a6bb7d8 fix(planner): clear place search input when modal closes 2026-09-23 09:07:48 +02:00
Maurice d609e1e202 Merge pull request #2466 from liketrek/docs/wiki-mcp-openwebui-callback
docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
2026-09-23 08:49:56 +02:00
Maurice 6f3977e4c1 docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
The MCP setup page gave Open WebUI's redirect URI as
{your Open WebUI URL}/oauth/clients/{connection id}/callback. Open WebUI puts
mcp: in front of the ID it builds that path from, so a client registered by
hand with the bare ID never matched and the consent step answered
"Unregistered redirect_uri". The page now names the real path and adds how
Open WebUI connects: it registers and trades the code from its own server, so
its container has to reach the public TREK address, and its WebUI URL has to
be the public https address.
2026-09-23 08:28:29 +02:00
Maurice 615e74aff6 fix(pwa): an upgrade counts as applied once the new build has taken over
After a server upgrade the app wrote the new version into its marker
before the service worker had fetched the new build. When that fetch
failed, when the new precache broke off halfway, or when the browser had
already swapped workers before the version check answered, the page kept
the old bundle, and every later launch took the marker at its word and
never tried again. An installed PWA went on showing the previous UI.

The marker now moves only once the handover is done or turned out to be
unnecessary, so a failed attempt is simply retried on the next launch. A
failure no longer reloads into the shell the old worker serves again, a
worker that took over before the check answered still gets its reload,
and no path reloads more than once per version and session. Caches and
workers are still never deleted.
2026-09-22 21:48:23 +02:00
Maurice bb515ac3cc fix(planner): booking codes stay blurred in the edit forms too
With "Blur booking codes" on, the bookings list and the transport details
hid a code, but opening the same booking to edit it printed the code in
the clear: the reservation and transport forms with every per-segment
code of a flight or train, the hotel editor in the day panel and the
three phone sheets. The stay's own code in the day panel, the booking
strip in the place inspector and the booking lines under a stay and a
stop on the phone showed it plainly as well.

One shared BookingCodeInput and BlurredCode now carry the setting to all
of them. A filled code field stays blurred until it is hovered or
focused, so clicking, tapping or tabbing into it still reveals the code
for editing, and the value is saved exactly as typed. With the setting
off nothing changes, and the PDF export and the share pages stay as
they were.
2026-09-22 21:48:22 +02:00
Maurice 93dddaa4af fix(mobile): booking routes on the phone map follow the day they run on
A booking switched on with "On map" stayed drawn on every day of the
phone's plan map, so a flight from the first day crossed the map of the
third, although a day chip frames one day and drops the other days' pins.
The plan map now draws such a booking only on the days it runs on, the
arrival day of an overnight one included, and the all-days view still
shows every booking that is switched on. The switch itself stays
trip-wide, and the desktop, collections and the road trip stage draw
exactly as before.

"On map" in a booking's sheet now reads as on only where the plan map
draws the route. From any other day a tap takes the map to the booking's
day, switching the route on when it is off, instead of hiding a route
that is not drawn there anyway.

The phone settings also gain the "Always show booking routes" switch the
desktop already had. As on the desktop it sets the default for trips not
yet touched on this device, so a trip where a route was switched on or
off by hand keeps that choice.
2026-09-22 21:48:22 +02:00
Maurice 1a8d5a3dd9 fix(roadtrip): a stop with a start is driven where the day list shows it
The day list draws a day by its starts, the road trip drives the order
it is stored in. Moving a stop with a start onto another day, or adding
a place that carries one, stored it at the end of the day or where it
was dropped, so the list showed it among the stops its hour falls
between while the road trip went there last. It is now stored right
behind the row the list draws it after, with a booked night read by its
check-in as the server's time sort reads it. A stop without a start
lands exactly where it did before, and wherever a moved stop lands in
the middle of a day, the via points drawn on that day stay on the legs
they were drawn for.

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

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

Closes #2440
2026-09-22 10:32:08 +02:00
714 changed files with 41389 additions and 3801 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 4.3.0 version: 4.3.3
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "4.3.0" appVersion: "4.3.3"
+3
View File
@@ -8,6 +8,9 @@
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs <!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). --> before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script> <script src="/theme-boot.js"></script>
<!-- Recovers a shell whose entry module cannot load (#2524). Classic for the
same reasons, and ahead of the entry, which it has to watch. -->
<script src="/shell-guard.js"></script>
<!-- PWA / iOS --> <!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/client", "name": "@trek/client",
"version": "4.3.0", "version": "4.3.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+154
View File
@@ -0,0 +1,154 @@
/*
* Boot guard for the app shell (#2524).
*
* Loaded as an external classic script in <head> (NOT a module), like
* theme-boot.js, so it runs before the entry module and complies with the
* production CSP (script-src 'self').
*
* The entry module and every chunk it imports statically have to load before
* any of the app's own code runs. When one of them does not (a service worker
* precache that keeps index.html under a chunk's name, a proxy that hands out a
* stale file), the module script fails as a whole: nothing renders, and no error
* screen or reload button of the app is there to help. A reload changes nothing
* either, since the same precache answers it. This notices exactly that failure
* and, while the server answers, loads the page again from the server under a
* URL no cache has seen. When a service worker served the page, the app shell
* it keeps (the Workbox precache and the worker itself) is thrown away first,
* since that is what answered with the broken file. Once per session, so a build
* that is broken on the server cannot put the page in a reload loop. Offline
* nothing happens: the precache is then the only way the app can start at all.
*
* It also strips the query parameter such a reload carries, before the app
* reads the URL. Keep the parameter and the cache filter in sync with
* src/utils/chunkReload.ts and src/utils/serviceWorkerShell.ts.
*
* It must never throw.
*/
(function () {
var PARAM = 'trek-reload';
var MARKER = 'trek:shell-reload';
var HEALTH_TIMEOUT_MS = 3000;
try {
var here = new URL(window.location.href);
if (here.searchParams.has(PARAM)) {
here.searchParams.delete(PARAM);
window.history.replaceState(window.history.state, '', here.pathname + here.search + here.hash);
}
} catch (e) {
/* the parameter stays in the address bar, which is harmless */
}
// The built entry lives under /assets/. The dev server's /src/main.tsx does
// not, so a syntax error while developing is left to Vite's own overlay.
function isEntry(el) {
return !!el && el.tagName === 'SCRIPT' && el.type === 'module' && /\/assets\//.test(el.src || '');
}
function alreadyTried() {
try {
return !!sessionStorage.getItem(MARKER);
} catch (e) {
// Without the marker there is no loop protection, so do nothing.
return true;
}
}
function markTried() {
try {
sessionStorage.setItem(MARKER, String(Date.now()));
return true;
} catch (e) {
return false;
}
}
// Same test as probeNow() in src/sync/connectivity.ts: TREK's own health JSON,
// not an edge proxy's login page and not a request that never came back.
function serverAnswers() {
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, HEALTH_TIMEOUT_MS) : null;
return fetch('/api/health', {
credentials: 'include',
cache: 'no-store',
redirect: 'manual',
signal: ctrl ? ctrl.signal : undefined,
}).then(function (res) {
var type = res.headers.get('content-type') || '';
return res.ok && type.indexOf('application/json') !== -1;
}, function () {
return false;
}).then(function (ok) {
if (timer) clearTimeout(timer);
return ok;
});
}
function servedByWorker() {
try {
return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
} catch (e) {
return false;
}
}
function dropAppShell() {
var jobs = [];
if (typeof caches !== 'undefined') {
jobs.push(caches.keys().then(function (names) {
return Promise.all(names.filter(function (name) {
return name.indexOf('-precache-') !== -1;
}).map(function (name) {
return caches.delete(name);
}));
}));
}
if (navigator.serviceWorker) {
jobs.push(navigator.serviceWorker.getRegistration().then(function (reg) {
return reg ? reg.unregister() : false;
}));
}
return Promise.all(jobs);
}
function reloadFresh() {
var url = new URL(window.location.href);
url.searchParams.set(PARAM, Date.now().toString(36));
window.location.replace(url.href);
}
function recover() {
if (alreadyTried()) return;
// Read now: a page the worker did not serve failed on the network, and its
// precache has nothing to do with it.
var fromWorker = servedByWorker();
serverAnswers().then(function (ok) {
if (!ok || alreadyTried() || !markTried()) return;
var dropped = fromWorker ? dropAppShell() : Promise.resolve();
return dropped.then(null, function () {
/* reload regardless, the server has the current build */
}).then(reloadFresh);
}).then(null, function () {
/* never block boot */
});
}
try {
// Error and load events of a script do not bubble, so both are caught on
// their way down. On the document, since a load event never reaches window.
document.addEventListener('error', function (event) {
if (isEntry(event.target)) recover();
}, true);
// The shell started, so a later update in this session may recover too.
document.addEventListener('load', function (event) {
if (!isEntry(event.target)) return;
try {
sessionStorage.removeItem(MARKER);
} catch (e) {
/* nothing to clear */
}
}, true);
} catch (e) {
/* never block boot */
}
})();
+27 -7
View File
@@ -580,24 +580,44 @@ describe('Version cache-busting', () => {
expect(reload).not.toHaveBeenCalled() expect(reload).not.toHaveBeenCalled()
}) })
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => { it('FE-COMP-APP-027: an update path that throws is retried on the next launch, harmlessly (#2228)', async () => {
// The marker used to be written after the purge and outside its try, so a // A handover that never happened must not be recorded as done, or the
// throwing step left it unwritten and the whole destructive path repeated on // device stays on the old build for good. The retry this buys has to be
// every launch, for good. // harmless: the purge that once repeated on every launch is gone, and a
// failure neither deletes anything nor reloads into the old worker's shell.
localStorage.setItem('trek_app_version', '2.9.9') localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
const getRegistration = vi.fn().mockRejectedValue(new Error('no worker'))
Object.defineProperty(navigator, 'serviceWorker', { Object.defineProperty(navigator, 'serviceWorker', {
configurable: true, configurable: true,
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() }, value: { getRegistration, getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn() },
}) })
server.use( server.use(
http.get('/api/auth/app-config', () => http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' }) HttpResponse.json({ version: '2.9.10', places_provider: 'google' })
) )
) )
seedAuth() seedAuth()
const first = renderApp('/')
await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(1))
await act(async () => { await Promise.resolve() })
first.unmount()
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
renderApp('/') renderApp('/')
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')) await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(2))
await act(async () => { await Promise.resolve() })
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
// Without a reload the session carries on, so the rest of the config applies.
expect(useAuthStore.getState().placesProvider).toBe('google')
}) })
}) })
+6 -43
View File
@@ -17,6 +17,7 @@ import MobileShell from './mobile/MobileShell'
import MRouteFallback from './mobile/components/MRouteFallback' import MRouteFallback from './mobile/components/MRouteFallback'
import ErrorBoundary from './components/shared/ErrorBoundary' import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry' import { lazyWithRetry } from './utils/lazyWithRetry'
import { reconcileAppVersion } from './utils/versionHandover'
import { useIsPhone } from './mobile/useIsPhone' import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client' import { authApi, isAuthPublicPath } from './api/client'
@@ -284,7 +285,7 @@ function RouteFallback() {
} }
export default function App() { export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore() const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore() const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore() const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore() const { loadPlugins } = usePluginStore()
@@ -301,7 +302,7 @@ export default function App() {
loadUser() loadUser()
} }
} }
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => { authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed) setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode) setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true) if (config?.dev_mode) setDevMode(true)
@@ -309,6 +310,7 @@ export default function App() {
if (config?.version) setAppVersion(config.version) if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key) if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.places_provider) setPlacesProvider(config.places_provider)
if (config?.timezone) setServerTimezone(config.timezone) if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
@@ -318,47 +320,8 @@ export default function App() {
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled) if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled) if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// Last, since a new release reloads the page from here.
// A version is a short release tag and nothing else. It arrives over the await reconcileAppVersion(config?.version)
// wire and is written to this device's storage, so only a value made of
// the characters a tag may contain is taken, as the match itself. Anything
// else is ignored, which also keeps a malformed value from being compared
// against the stored marker and starting an update on every launch.
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
if (releaseTag) {
const version = releaseTag[0]
const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private
// mode, blocked site data, quota) left the caches deleted, the marker
// unwritten and the reload unreached, and the purge then repeated on
// every single launch, permanently (#2228).
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
if (storedVersion && storedVersion !== version) {
// A newer build is deployed. Ask the service worker to fetch it and
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
// installs the new precache and only then drops the outdated one, so
// there is never a moment without an app shell.
//
// This used to delete EVERY Cache Storage bucket and unregister EVERY
// worker instead. That left the device with no shell and no worker
// until a fresh ~22 MB precache finished (minutes on mobile data),
// and anyone who closed the app or lost signal in that window was
// left with a PWA that could no longer start offline at all. It also
// threw away the map tiles and file blobs the user had deliberately
// downloaded for offline use (#2228).
try {
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
if (reg) {
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
await reg.update()
return
}
} catch { /* fall through to a plain reload */ }
window.location.reload()
return
}
}
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
+281
View File
@@ -0,0 +1,281 @@
import React from 'react'
import { render, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { resetAllStores } from '../tests/helpers/store'
import App from './App'
/*
* #2458: an installed PWA kept serving the previous UI after a server upgrade.
*
* The launch probe compares the server's version with a marker in localStorage
* and, on a mismatch, asks the service worker to fetch the new build and reloads
* once it has taken over. These tests drive that probe across consecutive
* launches against a fake service-worker container, so a handover that never
* completed can be told apart from one that did.
*
* The fake is installed before App is imported (vi.hoisted), so code that
* notes the controlling worker when its module loads sees the worker that
* served this page, exactly as in the browser.
*/
const sw = vi.hoisted(() => {
class FakeWorker extends EventTarget {
state: string
readonly scriptURL = '/sw.js'
constructor(state: string) {
super()
this.state = state
}
become(state: string) {
this.state = state
this.dispatchEvent(new Event('statechange'))
}
}
class FakeRegistration extends EventTarget {
active: FakeWorker | null = null
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
readonly scope = '/'
update = vi.fn()
}
class FakeContainer extends EventTarget {
controller: FakeWorker | null = null
getRegistration = vi.fn()
// Listeners a page adds die with that page. Those added while modules load
// (before `trackPage` is switched on) stand for module-level code and stay.
trackPage = false
private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = []
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
super.addEventListener(type, listener, options)
if (this.trackPage && listener) this.pageListeners.push([type, listener, options])
}
dropPageListeners() {
for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options)
this.pageListeners = []
}
}
const oldWorker = new FakeWorker('activated')
const registration = new FakeRegistration()
const container = new FakeContainer()
registration.active = oldWorker
container.controller = oldWorker
container.getRegistration.mockImplementation(async () => registration)
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
return { FakeWorker, registration, container, oldWorker }
})
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() }))
// Every import above has been evaluated by now; from here on a listener belongs to a page.
sw.container.trackPage = true
const reload = vi.fn()
let configHits = 0
function serveVersion(version: string, gate?: Promise<void>) {
server.use(
http.get('/api/auth/app-config', async () => {
configHits++
if (gate) await gate
return HttpResponse.json({ version })
}),
)
}
/** The browser's handover: the new worker installs, activates and claims the page. */
function handOver() {
const next = new sw.FakeWorker('activated')
sw.registration.installing = null
sw.registration.active = next
sw.container.controller = next
sw.container.dispatchEvent(new Event('controllerchange'))
}
/** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */
function updateStartsInstall(then: 'activates' | 'fails') {
sw.registration.update.mockImplementation(async () => {
const next = new sw.FakeWorker('installing')
sw.registration.installing = next
setTimeout(() => {
if (then === 'activates') {
next.become('activated')
handOver()
} else {
// Workbox precaching is all-or-nothing: one failed fetch out of ~463
// rejects the install event and the new worker is thrown away.
sw.registration.installing = null
next.become('redundant')
}
}, 0)
return sw.registration
})
}
/** One cold start of the installed app: fresh page, fresh session, same localStorage. */
async function launch() {
sessionStorage.clear()
const hitsBefore = configHits
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(hitsBefore + 1))
// Let the probe, update() and the fake install run to completion.
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
configHits = 0
sw.registration.active = sw.oldWorker
sw.registration.installing = null
sw.registration.waiting = null
sw.registration.update.mockReset()
sw.registration.update.mockResolvedValue(sw.registration)
sw.container.controller = sw.oldWorker
sw.container.dropPageListeners()
sw.container.getRegistration.mockReset()
sw.container.getRegistration.mockImplementation(async () => sw.registration)
reload.mockReset()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })),
http.get('/api/system-notices/active', () => HttpResponse.json([])),
)
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
user: null,
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
})
})
describe('#2458 version handover: behaviour that must survive the fix', () => {
it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
expect(localStorage.getItem('trek_app_version')).toBe('4.3.1')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => {
// A release that changed nothing the precache holds leaves sw.js byte-identical,
// so update() installs nothing. The bundle this page runs is already current.
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
})
it.each([
['a self-built image without the build arg', 'dev'],
['a test instance pinned to an older tag', '4.0.0'],
['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'],
])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => {
// The server reports APP_VERSION, the bundle carries client/package.json. On
// these setups the two never agree, so a check against the bundle's own
// version would see an update on every launch.
serveVersion(serverVersion)
await launch()
await launch()
await launch()
expect(reload).not.toHaveBeenCalled()
expect(sw.registration.update).not.toHaveBeenCalled()
})
})
describe('#2458 version handover: the reported defect', () => {
it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
// Next cold start, network fine this time.
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalled()
})
it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => {
// The old worker still controls the page, so a plain reload is answered from
// its precache: the same old UI, now with the marker saying it is current.
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch()
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('fails')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalledTimes(1)
})
// Kept last: it swaps the controlling worker mid-launch, which code that
// remembers "the controller changed since this page loaded" keeps seeing.
it('FE-COMP-APP-2458-04: a worker that took over before the probe answered still reloads the page', async () => {
// The browser checks sw.js on every navigation by itself. When that check
// installs and activates the new worker before /api/auth/app-config has
// answered, the takeover happens while nobody listens, and update() then
// finds nothing newer. The page keeps running the bundle the old worker served.
localStorage.setItem('trek_app_version', '4.3.0')
let open!: () => void
const gate = new Promise<void>(r => { open = r })
serveVersion('4.3.1', gate)
sessionStorage.clear()
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(1))
act(() => handOver())
open()
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
expect(reload).toHaveBeenCalledTimes(1)
})
})
+15 -1
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-057 // FE-APISURF-001 to FE-APISURF-059
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios' import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
@@ -455,6 +455,7 @@ describe('client > endpoint wiring', () => {
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' }, { n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' }, { n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' }, { n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
{ n: 'freezeRates', r: () => budgetApi.freezeRates(1, {}), e: 'POST /api/trips/1/budget/freeze-rates' },
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' }, { n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' }, { n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
]) ])
@@ -572,6 +573,19 @@ describe('client > request payloads', () => {
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] }) expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
}) })
it('FE-APISURF-059: the settlement carries the display rate only when the caller has one', async () => {
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', 0.61))).url).toBe('/api/trips/1/budget/settlement?base=EUR&base_rate=0.61')
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', null))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
const lent = { fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }
expect((await traceOne(() => budgetApi.freezeRates(1, lent))).body).toEqual(lent)
})
it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => {
const rec = await traceOne(() => daysApi.create(1, { dated: true }))
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } })
})
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => { it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] }) expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] }) expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
+22 -10
View File
@@ -1,7 +1,7 @@
import { isEffectivelyOffline } from '../sync/networkMode' import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod' import type { z } from 'zod'
import type { Place } from '../types' import type { Day, Place, Trip } from '../types'
import type { TransitProvider } from '@trek/shared' import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId' import { randomId } from '../utils/randomId'
import { import {
@@ -40,6 +40,8 @@ import {
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest, type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest, type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
type BudgetCreateSettlementRequest, type BudgetUpdateSettlementRequest, type BudgetSettlementQuery,
type BudgetFreezeRatesRequest, type BudgetFreezeRatesResponse,
type TodoCategoryAssigneesRequest, type TodoCategoryAssigneesRequest,
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest, type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest, type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
@@ -434,11 +436,17 @@ export const tripsApi = {
export const daysApi = { export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), // `trip` comes along when the new day changed the trip itself: a dated day
// (`dated: true`) moved its end date, an insert at a position grew it by a day.
create: (tripId: number | string, data: DayCreateRequest): Promise<{ day: Day; trip?: Trip }> =>
apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
// Whole-day default route mode (#1281); per-segment leg modes override it. // Whole-day default route mode (#1281); per-segment leg modes override it.
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data), updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), // Answers with the trip in list shape: its day count changed, and its end
// date when the day took the last date along.
delete: (tripId: number | string, dayId: number | string): Promise<{ success: boolean; trip?: Trip }> =>
apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
} }
@@ -1216,11 +1224,11 @@ export const mapsApi = {
* caller of this one function gets them without knowing they exist. Appended rather * caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order, * 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.
*/ *
/**
* `provider: 'google'` sends this one search to Google alone, the "search Google * `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 * instead" link under a list the index answered with the wrong place. The server
* server ignores it on an instance without a Google key. * ignores it unless Google holds the keyed slot: without a Google key, or with
* Amap or OpenStreetMap picked as the provider, the index answers as usual.
*/ */
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') => 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 () => {
@@ -1346,9 +1354,13 @@ export const budgetApi = {
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data), togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data), setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), // `baseRate` is units of `base` per 1 trip currency from the client's own rates. The server
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), // uses it only in place of a display quote it cannot fetch itself, which also reads legacy
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), // transfers without a currency (in the display currency, as with a live quote).
settlement: (tripId: number | string, base?: string, baseRate?: number | null) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base, ...(baseRate != null ? { base_rate: baseRate } : {}) } satisfies BudgetSettlementQuery } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: BudgetCreateSettlementRequest) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: BudgetUpdateSettlementRequest) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
freezeRates: (tripId: number | string, data: BudgetFreezeRatesRequest): Promise<BudgetFreezeRatesResponse> => apiClient.post(`/trips/${tripId}/budget/freeze-rates`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -1,4 +1,4 @@
// FE-W4BGT-001 to FE-W4BGT-022 // FE-W4BGT-001 to FE-W4BGT-024
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, act, waitFor } from '@testing-library/react' import { screen, act, waitFor } from '@testing-library/react'
import { render, fireEvent } from '../../../tests/helpers/render' import { render, fireEvent } from '../../../tests/helpers/render'
@@ -276,3 +276,31 @@ describe('BackgroundTasksWidget — AI retry', () => {
expect(await screen.findByText('No model configured')).toBeInTheDocument() expect(await screen.findByText('No model configured')).toBeInTheDocument()
}) })
}) })
/**
* The reporter's phone showed a green tick beside "No reservations could be
* extracted" and two warnings (#2477). A parse that found nothing is not a
* success, so it wears the warning mark; the tick stays for a parse that did.
*/
describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => {
it('FE-W4BGT-023: a job that found nothing shows the warning mark, not the success tick', () => {
useBackgroundTasksStore.setState({
tasks: [task({ items: [], warnings: ['Incomplete LodgingReservation in booking.pdf[0] (no reservationFor)'] })],
})
const { baseElement } = render(<BackgroundTasksWidget />)
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toHaveAttribute('stroke', 'var(--warning)')
expect(baseElement.querySelector('svg.lucide-check-circle2')).toBeNull()
expect(baseElement.querySelector('.animate-spin')).toBeNull()
})
it('FE-W4BGT-024: a job with items keeps the success tick, even with warnings', () => {
useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never, warnings: ['could not locate "X"'] })] })
const { baseElement } = render(<BackgroundTasksWidget />)
// The theme's own success colour, so it follows light and dark.
expect(baseElement.querySelector('svg.lucide-check-circle2')).toHaveAttribute('stroke', 'var(--success)')
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull()
})
})
@@ -1,7 +1,7 @@
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react' import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi, healthApi } from '../../api/client' import { reservationsApi, healthApi } from '../../api/client'
@@ -141,8 +141,12 @@ export default function BackgroundTasksWidget() {
> >
<div style={{ flexShrink: 0, marginTop: 1 }}> <div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />} {(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />} {task.status === 'done' && task.items !== undefined && task.items.length > 0 && <CheckCircle2 size={16} color="var(--success)" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />} {/* A parse that found nothing is not a success, whatever the job
status says: the same card used to show a green tick next to
"no reservations could be extracted" (#2477). */}
{task.status === 'done' && task.items?.length === 0 && <AlertTriangle size={16} color="var(--warning)" />}
{task.status === 'error' && <AlertCircle size={16} color="var(--danger)" />}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -15,6 +15,7 @@ import type { BudgetParticipantFinal } from '@trek/shared'
import type { BudgetItem } from '../../types' import type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal } from './CostsPanel' import CostsPanel, { ExpenseModal } from './CostsPanel'
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers' import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
import { resetFreezeAttempts } from './useFreezeMissingRates'
const tripMembers = [ const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null }, { id: 1, username: 'alice', avatar_url: null },
@@ -875,10 +876,11 @@ describe('CostsPanel — overview', () => {
const alice = within(card).getByRole('button', { name: /You/ }) const alice = within(card).getByRole('button', { name: /You/ })
await user.click(alice) await user.click(alice)
expect(alice).toHaveAttribute('aria-expanded', 'true') expect(alice).toHaveAttribute('aria-expanded', 'true')
// The dinner is the server's 92 €, once as the line and once as its only row; // The dinner is the server's 92 €, once as the line and once as its only row; the
// the raw 100 USD never shows up in the card. // 100 USD is never converted again here. What was entered labels the row (#2525).
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2) expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
expect(within(card).queryByText(/100,00/)).toBeNull() expect(within(card).queryByText(/100,00/)).toBeNull()
expect(within(card).getByText('· $100.00')).toBeInTheDocument()
// Received and still pending both lower her cost, each line with its one row. // Received and still pending both lower her cost, each line with its one row.
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2) expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2) expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
@@ -2293,3 +2295,390 @@ describe('CostsPanel — expense modal in another language', () => {
expect(screen.queryByText('Excluded')).not.toBeInTheDocument() expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
}) })
}) })
// ── #2525: a bill entered in the display currency on a trip kept in another ───
describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
// The reporter's setup: a euro trip, read in dollars, with the bill entered in
// dollars. It was booked at 1.17 dollars to the euro, 685.26 EUR, and that is what
// the balances and settle-up net. Today the euro buys 1.1551 dollars, so those
// euros are $791.55. The dollar's own quote is rounded separately (0.865706 per
// dollar is 1.155126 per euro), which is why the panel converts with the euro's.
beforeEach(() => {
seedAlice()
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
clearExchangeRateCache()
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551, SEK: 10 }, ts: Date.now() }))
})
afterEach(clearExchangeRateCache)
const hotel = (over: Partial<BudgetItem> = {}) => expense({
id: 401, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
expense_date: '2026-08-06',
payers: [{ user_id: 1, amount: 801.76 }],
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
...over,
})
it('FE-W5COSTS-079: a bill whose rate moved shows what was entered and what it was booked at', async () => {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
// the 801.76 in the dialog. The pill beside the line is the last step.
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
// Row total and payer chip stay the booked euros at today's rate, and so does what
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
// had Bob pay back more than he owed and reopened the trip.
expect(screen.getAllByText('$791.55')).toHaveLength(2)
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
expect(screen.queryByText('you lent $400.88')).toBeNull()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
mount([
hotel({ id: 407, name: 'Villa', total_price: 12345.67, exchange_rate: 1.1551, payers: [{ user_id: 1, amount: 12345.67 }] }),
hotel({ id: 408, name: 'Unpaid deposit', total_price: 250, exchange_rate: 1.1551, payers: [], members: [] }),
])
await screen.findByText('Villa')
// Pill and payer chip; converting back with the dollar's own quote gave $12,346.05.
expect(screen.getAllByText('$12,345.67')).toHaveLength(2)
expect(screen.queryByText(/12,346\.0[56]/)).toBeNull()
expect(screen.getByText('$12,595.67 spent')).toBeInTheDocument()
const total = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(total.textContent).toContain('$12,595.67')
const outstanding = screen.getByText('Outstanding amount').parentElement!.parentElement!.parentElement!
expect(outstanding.textContent).toContain('$250.00')
// Nothing to explain under either row.
expect(screen.queryByText(/^\$12,345\.67 →/)).toBeNull()
expect(screen.queryByText(/^\$250\.00 →/)).toBeNull()
})
it('FE-W5COSTS-088: the CSV export adds the trip-currency amount every sum is built from', async () => {
let exported: Blob | null = null
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
try {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
fireEvent.click(screen.getByTitle('Export CSV'))
await waitFor(() => expect(exported).toBeTruthy())
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
})
it('FE-W5COSTS-080: the totals add up the figures the rows show', async () => {
mount([
hotel(),
hotel({ id: 402, name: 'Dinner', category: 'food', total_price: 120, payers: [{ user_id: 2, amount: 120 }] }),
])
await screen.findByText('Aparthotel Silver')
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
// $910.02, on the day header and on the total spend card.
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
// Label, its text block, the header row, then the card itself.
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(card.textContent).toContain('$910.02')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
// Nothing cached: the rates land after the expenses and the settlement, and the
// totals used to keep the figure they were first added up with.
clearExchangeRateCache()
server.use(http.get('https://api.frankfurter.dev/v2/rates', async () => {
await new Promise(r => setTimeout(r, 150))
return HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
}))
mount([hotel({ id: 406, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
// 100 euro at 0.8 euro to the dollar.
expect(await screen.findByText(/100,00\s€ → \$125\.00/)).toBeInTheDocument()
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
await waitFor(() => expect(card.textContent).toContain('$125.00'))
})
it('FE-W5COSTS-081: a euro row converts live, a third currency shows the euros it was booked at', async () => {
mount([
hotel({ id: 403, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] }),
hotel({ id: 404, name: 'Ferry', category: 'transport', total_price: 1000, currency: 'SEK', exchange_rate: 10, payers: [{ user_id: 1, amount: 1000 }] }),
// Written before the freeze existed: nothing was booked, so dollars stay dollars.
hotel({ id: 405, name: 'Old taxi', category: 'transport', total_price: 50, currency: 'USD', exchange_rate: 1, payers: [{ user_id: 1, amount: 50 }] }),
])
await screen.findByText('Museum')
// The trip currency has no frozen rate of its own and goes to dollars live.
expect(screen.getByText(/^100,00\s€ → \$115\.51$/)).toBeInTheDocument()
// 1000 SEK at 10 per euro is 100 euro, then live to dollars in the pill.
expect(screen.getByText(/^1\s000,00\skr → 100,00\s€$/)).toBeInTheDocument()
expect(screen.getAllByText('$50.00').length).toBeGreaterThanOrEqual(2)
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
})
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
mount([hotel()], {
settlements: [
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
// Rows from before transfers had a currency were entered in the display
// currency, which is how the server settles them.
{ id: 22, from_user_id: 2, to_user_id: 1, amount: 30, currency: null, created_at: '2026-08-08 09:00:00' },
],
})
await screen.findByText('Aparthotel Silver')
expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
expect(screen.getByText('$395.77')).toBeInTheDocument()
expect(screen.getByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByTitle('Edit'))
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
// $791.57 in the list looked like two different expenses.
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
const user = userEvent.setup()
mount([hotel()], {
balances: [
{ user_id: 1, username: 'alice', avatar_url: null, balance: 395.78 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: -395.78 },
],
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 395.78 }],
finalBudgets: [
{
user_id: 1, username: 'alice', avatar_url: null, expenses: 791.55, reimbursed: 0, pending: 395.78, final: 395.77,
sources: { fronted: [{ item_id: 401, cents: 79155 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 39578 }] },
},
],
})
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
await user.click(await within(card).findByRole('button', { name: /You/ }))
expect(within(card).getAllByText('+$791.55')).toHaveLength(2)
expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument()
expect(within(card).getByText('· $801.76')).toBeInTheDocument()
})
it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => {
const user = userEvent.setup()
mount([hotel({ exchange_rate: 1.1551 })], {
balances: [
{ user_id: 1, username: 'alice', avatar_url: null, balance: 400.88 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: -400.88 },
],
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 400.88 }],
finalBudgets: [
{
user_id: 1, username: 'alice', avatar_url: null, expenses: 801.76, reimbursed: 0, pending: 400.88, final: 400.88,
sources: { fronted: [{ item_id: 401, cents: 80176 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 40088 }] },
},
],
})
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
await user.click(await within(card).findByRole('button', { name: /You/ }))
expect(within(card).getAllByText('+$801.76')).toHaveLength(2)
expect(within(card).queryByText('· $801.76')).toBeNull()
})
it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/405', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: hotel({ id: 405, currency: 'EUR' }) })
}))
mount([hotel({ id: 405, name: 'Tram pass', category: 'transport', total_price: 100, currency: null, exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
await screen.findByText('Tram pass')
// The list reads the missing currency as the trip's own.
expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument()
await user.click(screen.getByTitle('Edit'))
expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument()
// Seeding the display currency here would label 100 euro as 100 dollars, and a
// plain save would then store it that way.
expect(screen.getByText(/^EUR/)).toBeInTheDocument()
expect(screen.queryByText(/^USD/)).toBeNull()
// Saving without touching anything keeps the 100 euro.
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' }))
})
})
// ── A bill the server cannot convert: VND on an AUD trip ────────────────────────
describe('CostsPanel: a bill the server cannot convert', () => {
// The reporter's setup: an AUD trip whose server cannot fetch rates while the browser
// can. An 8,920,000 VND dinner that never froze a rate was counted as 8,920,000 AUD.
// The settlement now leaves it out and lists it, and an editor's browser lends its AUD
// table to freeze a rate on it.
const pho = () => expense({
id: 501, name: 'Pho', category: 'food', total_price: 8920000, currency: 'VND', exchange_rate: 1,
expense_date: '2026-09-20',
payers: [{ user_id: 1, amount: 8920000 }],
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
})
const leftOut = { balances: [], flows: [], settlements: [], currency: 'AUD', unconverted: { item_ids: [501], settlement_ids: [], currencies: ['VND'] } }
beforeEach(() => {
seedAlice()
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) })
clearExchangeRateCache()
resetFreezeAttempts()
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
})
afterEach(clearExchangeRateCache)
it('FE-W5COSTS-090: VND/AUD regression: an editor heals the bill with the cached quote and the healed balances load', async () => {
let healed = false
const frozen: unknown[] = []
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [pho()] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json(healed
? {
// 8,920,000 VND at 18,241.3 is 489.00 AUD, half of it Bob's.
balances: [
{ user_id: 1, username: 'alice', avatar_url: null, balance: 244.5 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: -244.5 },
],
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 244.5 }],
settlements: [],
currency: 'AUD',
unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
}
: leftOut)),
http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => {
frozen.push(await request.json())
healed = true
return HttpResponse.json({ items: [{ ...pho(), exchange_rate: 18241.3 }], settlements: [], unresolved: [] })
}),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// Only the currency the settlement is missing, at the rate the browser holds.
await waitFor(() => expect(frozen).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }]))
// The settlement is read again and Bob's half can be settled.
expect(await screen.findByRole('button', { name: 'Settle' })).toBeInTheDocument()
expect(screen.getAllByText(/244\.50/).length).toBeGreaterThan(0)
// Nothing on the page counts the bill as 8,920,000 dollars any more.
expect(screen.queryByText(/\$8,920,000/)).toBeNull()
expect(frozen).toHaveLength(1)
})
it('FE-W5COSTS-091: a viewer\'s browser never freezes anything', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'admin' } })
let frozen = 0
server.use(http.post('/api/trips/1/budget/freeze-rates', () => {
frozen += 1
return HttpResponse.json({ items: [], settlements: [], unresolved: [] })
}))
mount([pho()], leftOut)
await screen.findByText('Pho')
await new Promise(r => setTimeout(r, 50))
expect(frozen).toBe(0)
})
it('FE-W5COSTS-092: settle-up in the display currency lends its rate and asks the settlement in it', async () => {
// Read in euros: the browser has the AUD table, the server has no quote at all.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
const asked: { base: string | null; baseRate: string | null }[] = []
const posted: Record<string, unknown>[] = []
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', ({ request }) => {
const url = new URL(request.url)
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
return HttpResponse.json({
balances: [], settlements: [], currency: 'EUR', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
})
}),
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ settlement: { id: 1 } })
}),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
fireEvent.click(await screen.findByRole('button', { name: 'Settle' }))
await waitFor(() => expect(posted).toHaveLength(1))
// The transfer freezes at the rate the settlement was labelled with, so it cancels its
// flow to the cent.
expect(posted[0]).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
expect(asked.length).toBeGreaterThan(0)
expect(asked.every(a => a.base === 'EUR' && a.baseRate === '0.61')).toBe(true)
const settleAll = screen.getByRole('button', { name: 'Settle up' })
fireEvent.click(settleAll)
await waitFor(() => expect(posted).toHaveLength(2))
expect(posted[1]).toMatchObject({ amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
})
it('FE-W5COSTS-093: a payment added by hand lends the rate for its currency too', async () => {
const user = userEvent.setup()
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
let posted: Record<string, unknown> | null = null
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 1 } })
}))
mount([])
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '25')
const submits = screen.getAllByRole('button', { name: 'Add payment' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted).toMatchObject({ amount: 25, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
})
it('FE-W5COSTS-094: a display rate the settlement query would refuse is not sent', async () => {
// A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the query
// takes, and sent as `base_rate` it would fail the whole settlement with a 400.
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XAU' }) })
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'IRR' } })
localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() }))
localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() }))
const asked: { base: string | null; baseRate: string | null }[] = []
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', ({ request }) => {
const url = new URL(request.url)
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
return HttpResponse.json({
balances: [], flows: [], settlements: [], currency: 'XAU', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
})
}),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await waitFor(() => expect(asked.length).toBeGreaterThan(0))
// Still asked in rials, only without the figure; the server answers in the trip
// currency when it has no quote of its own.
expect(asked.every(a => a.base === 'IRR' && a.baseRate === null)).toBe(true)
})
})
+84 -36
View File
@@ -9,7 +9,9 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client' import { budgetApi } from '../../api/client'
import { saveWithReceipts } from './receiptUploads' import { saveWithReceipts } from './receiptUploads'
import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates' import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates'
import { splitShareLabel, useExpenseFx } from './expenseFx'
import { useFreezeMissingRates } from './useFreezeMissingRates'
import { useIsMobile } from '../../hooks/useIsMobile' import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob, openFile } from '../../utils/fileDownload' import { downloadBlob, openFile } from '../../utils/fileDownload'
@@ -21,7 +23,7 @@ import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import { ReceiptPreviewModal } from './ReceiptPreviewModal' import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetParticipantFinal, BudgetUnconverted } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips' import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge' import GuestBadge from '../shared/GuestBadge'
@@ -59,6 +61,11 @@ interface SettlementData {
// What the trip ends up costing each participant. Computed server-side off the // What the trip ends up costing each participant. Computed server-side off the
// same ledger as the balances, so the breakdown can't contradict them. // same ledger as the balances, so the breakdown can't contradict them.
finalBudgets: BudgetParticipantFinal[] finalBudgets: BudgetParticipantFinal[]
// The currency the figures are in: the display currency, or the trip's own when
// neither the server nor `base_rate` could quote the pair.
currency?: string
// Rows no rate could convert, left out of every figure above.
unconverted?: BudgetUnconverted
} }
// One row in the unified Costs ledger — either an expense or a settle-up payment, // One row in the unified Costs ledger — either an expense or a settle-up payment,
@@ -85,7 +92,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency". // Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
const tripCurrency = (trip?.currency || base).toUpperCase() const tripCurrency = (trip?.currency || base).toUpperCase()
const { convert } = useExchangeRates(base) // Anchored on the trip currency's quote, the one the server books with (#2525).
const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency)
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
const [settlement, setSettlement] = useState<SettlementData | null>(null) const [settlement, setSettlement] = useState<SettlementData | null>(null)
// A failed settlement read leaves `settlement` null, which the empty views would // A failed settlement read leaves `settlement` null, which the empty views would
@@ -119,14 +127,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale]) const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale]) const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
// The browser's own figure for the display currency goes along, for the server to
// answer in it when it cannot fetch a quote itself.
const loadSettlement = useCallback(() => { const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base) budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
.then(s => { setSettlement(s); setSettlementError(false) }) .then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true)) .catch(() => setSettlementError(true))
}, [tripId, base]) }, [tripId, base, tripCurrency, displayPerTrip])
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId]) useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
useEffect(() => { loadSettlement() }, [budgetItems.length, base]) useEffect(() => { loadSettlement() }, [budgetItems.length, loadSettlement])
// Rows the server could not count get a rate frozen from the browser's, and the
// settlement is read again once they count.
useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement })
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense. // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@@ -145,12 +159,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
[convert, tripCurrency], [convert, tripCurrency],
) )
const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e) const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e)
// A transfer freezes its own rate at settle time, in its own table (#1445). // A transfer freezes its own rate at settle time, in its own table (#1445). One
// without a currency predates that and was entered in the display currency, which
// is how the server settles it too, not in the trip's.
const settled = useCallback( const settled = useCallback(
(s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert), (s: Settlement) => convertBooked(s.amount, s.currency || base, s.exchange_rate, tripCurrency, convert),
[convert, tripCurrency], [convert, tripCurrency, base],
) )
const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e) const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e)
// The line under an amount shown converted: what was entered, then where it went (#2525).
const lineOf = (amount: number, e: BudgetItem, shown: number) =>
convertedLine(amount, e.currency, e.exchange_rate, tripCurrency, base, shown)
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip // "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in. A negative // total but stays out of settlements until who-paid is filled in. A negative
// total (a refund, #2176) is just as unfinished until its recipient is named. // total (a refund, #2176) is just as unfinished until its recipient is named.
@@ -169,6 +188,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return booked(myShare, e) return booked(myShare, e)
} }
// `booked` carries the rates. They can land after the expenses, and without it in the
// deps the cards kept the sums they were first added up with while the rows moved on.
const totals = useMemo(() => { const totals = useMemo(() => {
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0) const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
@@ -178,7 +199,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0) const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
const outstandingCount = budgetItems.filter(isUnfinished).length const outstandingCount = budgetItems.filter(isUnfinished).length
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount } return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
}, [budgetItems, settlement, me]) }, [budgetItems, settlement, me, booked])
// ── filtering + day grouping ──────────────────────────────────────────── // ── filtering + day grouping ────────────────────────────────────────────
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -192,7 +213,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
return list return list
}, [budgetItems, filter, search, catFilter, dayFilter, me]) }, [budgetItems, filter, search, catFilter, dayFilter, me, booked])
// Settlements ("payments") shown inline in the ledger. They have no name, so a // Settlements ("payments") shown inline in the ledger. They have no name, so a
// text search hides them; they're excluded from the "owed" expense filter and, // text search hides them; they're excluded from the "owed" expense filter and,
@@ -245,7 +266,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// ── settle actions ────────────────────────────────────────────────────── // ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => { const settleFlow = async (fromId: number, toId: number, amount: number) => {
try { try {
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base }) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency))
loadSettlement() loadSettlement()
} catch { toast.error(t('common.unknownError')) } } catch { toast.error(t('common.unknownError')) }
} }
@@ -256,7 +277,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const flows = settlement?.flows || [] const flows = settlement?.flows || []
if (!flows.length) return if (!flows.length) return
try { try {
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }) for (const f of flows) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, tripCurrency))
} catch { toast.error(t('common.unknownError')) } } catch { toast.error(t('common.unknownError')) }
// Refresh even when one transfer failed: the ones created before it are real, // Refresh even when one transfer failed: the ones created before it are real,
// and leaving them in the flow list invites a second, doubled settle-up. // and leaving them in the flow list invites a second, doubled settle-up.
@@ -290,15 +311,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
} }
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } } const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note'] // Read in another currency than the trip's, what each row counts as in the trip
// currency too, the figure every sum is built from (#2525).
const tripCol = tripCurrency !== base
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? ['Amount (' + tripCurrency + ')'] : []), 'Amount (' + base + ')', 'Note']
const rows = [header.join(sep)] const rows = [header.join(sep)]
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
for (const e of items) { for (const e of items) {
const cur = curOf(e) const cur = curOf(e)
const note = readUserNote(e) const note = readUserNote(e)
const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, tripCurrency, convert)
rows.push([ rows.push([
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)), esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur, (e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
...(tripCol ? [inTrip.toFixed(currencyDecimals(tripCurrency))] : []),
baseTotal(e).toFixed(currencyDecimals(base)), baseTotal(e).toFixed(currencyDecimals(base)),
esc(note), esc(note),
].join(sep)) ].join(sep))
@@ -521,7 +547,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)} )}
{(editingSettlement || addingPayment) && ( {(editingSettlement || addingPayment) && (
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base} <SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base} tripCurrency={tripCurrency}
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }} onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)} )}
@@ -768,7 +794,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function ExpenseRow({ e }: { e: BudgetItem }) { function ExpenseRow({ e }: { e: BudgetItem }) {
const c = catMeta(e.category) const c = catMeta(e.category)
const Icon = c.Icon const Icon = c.Icon
const cur = curOf(e) const line = lineOf(e.total_price || 0, e, baseTotal(e))
const payers = (e.payers || []).filter(p => p.amount !== 0) const payers = (e.payers || []).filter(p => p.amount !== 0)
const net = round2(myPaidOf(e) - myShareOf(e)) const net = round2(myPaidOf(e) - myShareOf(e))
const unfinished = isUnfinished(e) const unfinished = isUnfinished(e)
@@ -837,9 +863,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</button> </button>
)} )}
</div> </div>
{cur !== base && ( {line && (
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))} {fmt(line.entered.amount, line.entered.currency)} {'→'} {fmt(line.into.amount, line.into.currency)}
</div> </div>
)} )}
{/* Under the name, because a chip reading "Y 122,00" says nothing on {/* Under the name, because a chip reading "Y 122,00" says nothing on
@@ -894,6 +920,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function SettlementRow({ s }: { s: Settlement }) { function SettlementRow({ s }: { s: Settlement }) {
// Legacy transfers carry no currency and were entered in the display base. // Legacy transfers carry no currency and were entered in the display base.
const cur = (s.currency || base).toUpperCase() const cur = (s.currency || base).toUpperCase()
// Booked in the trip currency like an expense, so it is explained the same way.
const line = convertedLine(s.amount, cur, s.exchange_rate, tripCurrency, base, settled(s))
return ( return (
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
<div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}> <div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
@@ -901,7 +929,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}> <div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
{t('costs.payment')} {t('costs.payment')}
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(settled(s))}</span>} {line && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}</span>}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}> <div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} /> <Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
@@ -1000,6 +1028,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// server's display cents, not a conversion of the expense list done here. // server's display cents, not a conversion of the expense list done here.
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v)) const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems) const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
// Beside a converted row's name, what was entered, so the breakdown names the bill
// the way the list above it does rather than by a figure nobody typed.
const enteredOf = (itemId: number, shown: number) => {
const e = budgetItems.find(i => i.id === itemId)
const line = e ? lineOf(paidByUser(e, row.user_id), e, shown) : null
return line ? fmt(line.entered.amount, line.entered.currency) : null
}
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
const detail = (label: string, value: string) => ( const detail = (label: string, value: string) => (
<div style={lineCls}> <div style={lineCls}>
@@ -1022,12 +1057,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div> <div>
<div className={labelCls}>{t('costs.finalExpenses')}</div> <div className={labelCls}>{t('costs.finalExpenses')}</div>
<div style={listCls}> <div style={listCls}>
{fronted.map(r => ( {fronted.map(r => {
<div key={r.item_id} style={lineCls}> const entered = enteredOf(r.item_id, r.amount)
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span> return (
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span> <div key={r.item_id} style={lineCls}>
</div> <span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
))} {r.name}{entered && <span className="text-content-faint"> · {entered}</span>}
</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
</div>
)
})}
</div> </div>
</div> </div>
)} )}
@@ -1145,8 +1185,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
// A transfer can be made in any currency — paying a rouble debt in euros is normal — // A transfer can be made in any currency — paying a rouble debt in euros is normal —
// so it carries its own, defaulting to the display currency. The server freezes its // so it carries its own, defaulting to the display currency. The server freezes its
// FX rate on write, the same way an expense's is frozen. // FX rate on write, the same way an expense's is frozen.
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: { function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; tripCurrency: string; onClose: () => void; onSaved: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
@@ -1167,7 +1207,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const save = async () => { const save = async () => {
if (!valid) return if (!valid) return
setSaving(true) setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day } const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency)
try { try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data) else await budgetApi.createSettlement(tripId, data)
@@ -1237,12 +1277,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const toast = useToast() const toast = useToast()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const { addBudgetItem, updateBudgetItem } = useTripStore() const { addBudgetItem, updateBudgetItem } = useTripStore()
const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ') const sym = (c: string) => SYMBOLS[c] || (c + ' ')
// A saved expense without a currency opens in the trip's own (#2525).
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
const [name, setName] = useState(editing?.name || prefill?.name || '') const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [currency, setCurrency] = useState(editingCurrency)
const [day, setDay] = useState(editing?.expense_date || localToday()) const [day, setDay] = useState(editing?.expense_date || localToday())
const [note, setNote] = useState(() => readUserNote(editing)) const [note, setNote] = useState(() => readUserNote(editing))
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB // Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
@@ -1250,7 +1291,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`, // 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
// which is also what the currency field starts on. // which is also what the currency field starts on.
const [total, setTotal] = useState<string>(() => { const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : '' if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
if (prefill?.amount != null) return amountToInputString(prefill.amount, base) if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
return '' return ''
}) })
@@ -1334,6 +1375,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}, [ticketItems]) }, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
const fx = preview(totalNum, currency)
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
// How much is still to be handed out, read on the total's own side: on a refund // How much is still to be handed out, read on the total's own side: on a refund
@@ -1591,12 +1633,18 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div> </div>
</div> </div>
{currency !== base && totalNum !== 0 && ( {fx && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span> <span>{formatMoney(totalNum, currency, locale)}</span>
<span className="text-content-faint">≈</span> {fx.inTrip != null && <>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span> <span className="text-content-faint">→</span>
<span className="text-content-faint">· {t('costs.liveRate')}</span> <span className={fx.shown == null ? 'text-content' : undefined} style={fx.shown == null ? { fontWeight: 600 } : undefined}>{formatMoney(fx.inTrip, tripCur, locale)}</span>
</>}
{fx.shown != null && <>
<span className="text-content-faint">≈</span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(fx.shown, base, locale)}</span>
<span className="text-content-faint">· {t('costs.liveRate')}</span>
</>}
</div> </div>
)} )}
@@ -1823,7 +1871,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}> <div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
{splitMode === 'equally' ? ( {splitMode === 'equally' ? (
<span className="text-content-faint"> <span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })}
</span> </span>
) : ( ) : (
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}> <span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { formatMoney } from '../../utils/formatters'
import { editingCurrencyOf, expenseFxPreview, splitShareLabel } from './expenseFx'
// A euro trip read in dollars, converted with the euro's own quote: one euro is 1.1551
// dollars today, and 1.17 on the day the hotel was booked (#2525).
const convert = (amount: number, from: string | null | undefined): number =>
(amount / ({ EUR: 1, USD: 1.1551, JPY: 170 } as Record<string, number>)[(from || 'USD').toUpperCase()]) * 1.1551
const hotel = { currency: 'USD', exchange_rate: 1.17 }
const base = { editing: hotel, editingCurrency: 'USD', tripCurrency: 'EUR', base: 'USD', convert }
describe('editingCurrencyOf', () => {
it('opens a saved expense without a currency in the trip currency', () => {
expect(editingCurrencyOf({ currency: null }, 'eur', 'USD')).toBe('EUR')
expect(editingCurrencyOf({ currency: 'jpy' }, 'EUR', 'USD')).toBe('JPY')
})
it('starts a new expense, or one on a trip without a currency, in the display currency', () => {
expect(editingCurrencyOf(null, 'EUR', 'usd')).toBe('USD')
expect(editingCurrencyOf({ currency: null }, null, 'usd')).toBe('USD')
})
})
describe('expenseFxPreview', () => {
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(791.55, 2)
})
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
expect(expenseFxPreview({ ...base, editing: { currency: 'USD', exchange_rate: 1.1551 }, total: 12345.67, currency: 'USD' })).toBeNull()
expect(expenseFxPreview({ ...base, editing: null, total: 250, currency: 'USD' })).toBeNull()
expect(expenseFxPreview({ ...base, total: 0, currency: 'USD' })).toBeNull()
})
it('previews a new or re-currencied amount at today\'s rate', () => {
expect(expenseFxPreview({ ...base, editing: null, total: 1700, currency: 'JPY' })).toEqual({ inTrip: null, shown: convert(1700, 'JPY') })
// Switching the hotel to yen re-freezes on save, so the old rate does not apply.
expect(expenseFxPreview({ ...base, total: 1700, currency: 'JPY' })?.inTrip).toBeNull()
})
it('stops at the trip amount when the viewer reads the trip currency', () => {
expect(expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })).toEqual({ inTrip: 801.76 / 1.17, shown: null })
})
})
describe('splitShareLabel', () => {
const sym = (c: string) => (c === 'USD' ? '$' : c + ' ')
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77')
// Read in the trip currency, the share is the booked euros.
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
})
it('prints the entered share alone when nothing is converted', () => {
expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00')
expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00')
})
})
+87
View File
@@ -0,0 +1,87 @@
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { currencyDecimals, formatMoney } from '../../utils/formatters'
/**
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
* phone MCostSheet so the two can never preview the same save differently (#2525). The
* editors only lay it out.
*/
interface EditedExpense {
currency?: string | null
exchange_rate?: number | null
}
/**
* The currency an edited expense opens in. A saved expense without a currency is in the
* trip's own, which is how the lists read it; opening it in the display currency relabelled
* the amount, and a save then stored it in that currency.
*/
export function editingCurrencyOf(editing: EditedExpense | null | undefined, tripCurrency: string | null | undefined, base: string): string {
return ((editing && (editing.currency || tripCurrency)) || base).toUpperCase()
}
export interface ExpenseFx {
/** The amount in the trip currency, when the save keeps a rate frozen on entry. */
inTrip: number | null
/** The amount in the display currency at today's rate; null when `inTrip` already is. */
shown: number | null
}
/**
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
* frozen when the expense was entered (the server re-freezes only when the currency changes),
* so it is previewed at that rate, the way the list will show it after the save. Anything
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
* in the display currency that still reads back as typed.
*/
export function expenseFxPreview(args: {
total: number
currency: string
editing: EditedExpense | null | undefined
editingCurrency: string
tripCurrency: string
base: string
convert: (amount: number, from: string | null | undefined) => number
}): ExpenseFx | null {
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
if (total === 0) return null
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
if (currency === base && inTrip == null) return null
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
const unit = 10 ** currencyDecimals(base)
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
}
/**
* One equal share as the split summary prints it, "$400.88", followed by what that share
* counts as when the amount is converted, "$400.88 → $390.54". That second figure is the
* one the row's "you lent" is made of, so the editor and the row cannot disagree.
*/
export function splitShareLabel(
each: number,
currency: string,
fx: ExpenseFx | null,
participants: number,
base: string,
sym: (currency: string) => string,
locale: string,
): string {
const entered = sym(currency) + each.toFixed(2)
const whole = fx ? (fx.shown ?? fx.inTrip) : null
if (whole == null || participants <= 0) return entered
return `${entered} → ${formatMoney(whole / participants, base, locale)}`
}
/** Everything an expense editor needs to preview a conversion, from the trip in the store. */
export function useExpenseFx(base: string, editing: EditedExpense | null | undefined) {
const storedTripCurrency = useTripStore(s => s.trip?.currency)
const tripCurrency = (storedTripCurrency || base).toUpperCase()
const { convert } = useExchangeRates(base, tripCurrency)
const editingCurrency = editingCurrencyOf(editing, storedTripCurrency, base)
const preview = (total: number, currency: string) =>
expenseFxPreview({ total, currency, editing, editingCurrency, tripCurrency, base, convert })
return { tripCurrency, editingCurrency, preview }
}
@@ -0,0 +1,123 @@
// FE-FREEZE-RATES-001 to FE-FREEZE-RATES-006
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import type { BudgetUnconverted } from '@trek/shared'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildBudgetItem } from '../../../tests/helpers/factories'
import { useTripStore } from '../../store/tripStore'
import { setForcedOffline } from '../../sync/networkMode'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetFreezeAttempts, useFreezeMissingRates } from './useFreezeMissingRates'
// The reporter's setup: an AUD trip whose server cannot fetch rates, and a VND bill that
// never froze one. The browser has today's AUD table.
const bill = () => buildBudgetItem({ id: 11, trip_id: 5, name: 'Pho', total_price: 8920000, currency: 'VND', exchange_rate: 1 })
const pending: BudgetUnconverted = { item_ids: [11], settlement_ids: [3], currencies: ['VND'] }
type Props = Parameters<typeof useFreezeMissingRates>[0]
function mountHook(over: Partial<Props> = {}) {
const onHealed = vi.fn()
const props: Props = { tripId: 5, tripCurrency: 'AUD', canEdit: true, unconverted: pending, onHealed, ...over }
const view = renderHook((p: Props) => useFreezeMissingRates(p), { initialProps: props })
return { ...view, onHealed, props }
}
let posted: unknown[] = []
function serveFreeze(reply: () => Response = () => HttpResponse.json({
items: [{ ...bill(), exchange_rate: 18241.3 }],
settlements: [{ id: 3, trip_id: 5, from_user_id: 2, to_user_id: 1, amount: 100000, currency: 'VND', exchange_rate: 18241.3 }],
unresolved: [],
})) {
server.use(http.post('/api/trips/5/budget/freeze-rates', async ({ request }) => {
posted.push(await request.json())
return reply()
}))
}
/** Lets a heal that was never going to send anything run to its end. */
const settle = () => act(async () => { await new Promise(r => setTimeout(r, 30)) })
beforeEach(() => {
resetAllStores()
resetFreezeAttempts()
clearExchangeRateCache()
posted = []
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
seedStore(useTripStore, { budgetItems: [bill()] })
serveFreeze()
})
afterEach(() => {
setForcedOffline(false)
vi.restoreAllMocks()
})
describe('useFreezeMissingRates', () => {
it('FE-FREEZE-RATES-001: lends the fresh rates for the listed currencies and reloads once rows count', async () => {
const { onHealed } = mountHook()
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
// Only the currency the settlement is missing goes out, never the whole table.
expect(posted).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }])
expect(useTripStore.getState().budgetItems[0].exchange_rate).toBe(18241.3)
})
it('FE-FREEZE-RATES-002: asks once per set of rows, however often the settlement is read', async () => {
const { onHealed, props, rerender } = mountHook()
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
// The same rows in another order, then again after a reload: nothing new to ask.
rerender({ ...props, unconverted: { item_ids: [11], settlement_ids: [3], currencies: ['VND'] } })
rerender({ ...props, unconverted: { settlement_ids: [3], item_ids: [11], currencies: ['VND'] } })
await settle()
expect(posted).toHaveLength(1)
// Another row is another attempt.
rerender({ ...props, unconverted: { item_ids: [11, 12], settlement_ids: [3], currencies: ['VND'] } })
await waitFor(() => expect(posted).toHaveLength(2))
})
it('FE-FREEZE-RATES-003: never asks for a viewer, offline, or with nothing left out', async () => {
mountHook({ canEdit: false })
mountHook({ unconverted: { item_ids: [], settlement_ids: [], currencies: [] } })
mountHook({ unconverted: undefined })
setForcedOffline(true)
mountHook({ unconverted: { item_ids: [21], settlement_ids: [], currencies: ['VND'] } })
await settle()
expect(posted).toEqual([])
})
it('FE-FREEZE-RATES-004: sends nothing when the browser has no rate for the currency either', async () => {
const { onHealed } = mountHook({ unconverted: { item_ids: [31], settlement_ids: [], currencies: ['JPY'] } })
await settle()
expect(posted).toEqual([])
expect(onHealed).not.toHaveBeenCalled()
})
it('FE-FREEZE-RATES-005: does not reload when the server froze nothing', async () => {
serveFreeze(() => HttpResponse.json({ items: [], settlements: [], unresolved: ['VND'] }))
const { onHealed } = mountHook()
await waitFor(() => expect(posted).toHaveLength(1))
await settle()
expect(onHealed).not.toHaveBeenCalled()
})
it('FE-FREEZE-RATES-006: a refused heal is logged, not thrown, and not retried in a loop', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
serveFreeze(() => HttpResponse.json({ error: 'The trip currency changed. Reload and try again.' }, { status: 409 }))
const { onHealed, props, rerender } = mountHook()
await waitFor(() => expect(warn).toHaveBeenCalled())
rerender({ ...props })
await settle()
expect(posted).toHaveLength(1)
expect(onHealed).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react'
import type { BudgetFallbackFx, BudgetUnconverted } from '@trek/shared'
import { fetchFreshRates, isSendableRate } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
/**
* Every set of rows already tried, by trip, trip currency and ids. The settlement lists a
* row it cannot count until a rate is frozen on it; one the browser has no rate for either
* stays listed, and without this every reload of the settlement would ask again.
*/
const tried = new Set<string>()
/** Test-only: the attempts outlive a vitest file's individual tests. */
export function resetFreezeAttempts(): void {
tried.clear()
}
/**
* Heals the rows the settlement leaves out (`unconverted`): expenses and transfers in a
* foreign currency that never froze a rate, while the server cannot fetch one. That is the
* setup where the server's own fetch fails and the browser's works, so an editor's browser
* lends its fresh rates to POST …/budget/freeze-rates. The server still prefers its own rate
* and never touches a row that is frozen already.
*
* Runs for an editor only, online, and once per set of rows. No request goes out when the
* browser has no rate for any of the listed currencies either. `onHealed` runs when the
* server froze something, so the caller can reload the settlement.
*
* Deliberately not in the mutation queue: the heal is derived and idempotent, and a replay
* hours later would freeze a rate from another day.
*/
export function useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted, onHealed }: {
tripId: number
tripCurrency: string
canEdit: boolean
unconverted: BudgetUnconverted | null | undefined
onHealed: () => void
}): void {
const onHealedRef = useRef(onHealed)
useEffect(() => { onHealedRef.current = onHealed })
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
const trip = (tripCurrency || '').toUpperCase()
const itemIds = [...(unconverted?.item_ids ?? [])].sort((a, b) => a - b).join(',')
const settlementIds = [...(unconverted?.settlement_ids ?? [])].sort((a, b) => a - b).join(',')
const currencies = (unconverted?.currencies ?? []).join(',')
useEffect(() => {
if (!canEdit || !trip || (!itemIds && !settlementIds) || isEffectivelyOffline()) return
const key = `${tripId}:${trip}:${itemIds}:${settlementIds}`
if (tried.has(key)) return
tried.add(key)
const heal = async (): Promise<void> => {
const rates = await fetchFreshRates(trip)
const lent: Record<string, number> = {}
for (const cur of currencies.split(',')) {
const rate = rates?.[cur]
if (isSendableRate(rate)) lent[cur] = rate
}
if (Object.keys(lent).length === 0) return
const fallback: BudgetFallbackFx = { base: trip, rates: lent }
const result = await useTripStore.getState().freezeMissingRates(tripId, fallback)
if (mounted.current && (result.items.length > 0 || result.settlements.length > 0)) onHealedRef.current()
}
heal().catch((err: unknown) => console.warn('Could not freeze the missing exchange rates:', err))
}, [canEdit, trip, tripId, itemIds, settlementIds, currencies])
}
@@ -1,4 +1,4 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008 // FE-COMP-LINKS-001 to FE-COMP-LINKS-013
vi.mock('../../api/websocket', () => ({ vi.mock('../../api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(), removeListener: vi.fn(),
})); }));
import { render, screen, waitFor } from '../../../tests/helpers/render'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
@@ -160,6 +162,67 @@ describe('CollabLinks', () => {
expect(screen.getByText('ferries.example')).toBeInTheDocument(); expect(screen.getByText('ferries.example')).toBeInTheDocument();
}); });
it('FE-COMP-LINKS-011: deleting asks first, and the DELETE goes out only once confirmed', async () => {
// The link goes for every member, and on the phone the button is a thumb's
// width from pin: the notes panel guards the same way.
const user = userEvent.setup();
let deleted = false;
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i }));
expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument();
expect(deleted).toBe(false);
expect(screen.getByText('Ferry timetable')).toBeInTheDocument();
// The confirm button of the question itself, found by its role rather than a colour class.
const question = screen.getByText(/delete link\?|collab\.links\.confirmDeleteTitle/i).closest('.trek-modal-enter') as HTMLElement;
await user.click(within(question).getByRole('button', { name: /^(delete|common\.delete)$/i }));
await waitFor(() => expect(deleted).toBe(true));
await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument());
});
it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => {
// The icon remembers a favicon that failed, and an edit keeps the chip's id,
// so the corrected address kept the generic glyph until the panel remounted.
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })),
http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })),
);
render(<CollabLinks tripId={1} />);
const chip = await screen.findByRole('link', { name: /ferry timetable/i });
fireEvent.error(chip.querySelector('img') as HTMLImageElement);
await waitFor(() => expect(chip.querySelector('img')).toBeNull());
await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i }));
const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i);
await user.clear(url);
await user.type(url, 'https://new.example/page');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img'))
.toHaveAttribute('src', 'https://new.example/favicon.ico'));
});
it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => {
// jsdom lays nothing out, so the rule is the assertion. The phone Collab tab
// mounts this chip; 22px buttons a pixel apart put delete one slip from pin.
const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions');
expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1);
const touch = css.slice(at, css.indexOf('\n}', at));
expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/);
const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? '';
for (const side of ['width', 'height']) {
expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24);
}
// The height comes back through the margin, so the chip itself stays 36px.
expect(action).toMatch(/margin:\s*-4px 0/);
});
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { 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' } });
+16 -2
View File
@@ -6,6 +6,7 @@ import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -111,6 +112,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
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) const [editing, setEditing] = useState<CollabLink | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
// Both are new objects on every render, so depending on them would reload the // 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.
@@ -230,7 +232,9 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{links.map(link => ( {links.map(link => (
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}> <div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}> <a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
<LinkIcon url={link.url} /> {/* Keyed on the address: the icon remembers a favicon that failed, and
an edit keeps the chip's id, so a corrected address got the glyph. */}
<LinkIcon key={link.url} url={link.url} />
<span className="collab-link-chip__text"> <span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span> <span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span> <span className="collab-link-chip__host">{hostOf(link.url)}</span>
@@ -244,7 +248,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}> <button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /> <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')}> <button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" /> <Trash2 size={13} aria-hidden="true" />
</button> </button>
</span> </span>
@@ -258,6 +262,16 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
{/* Create and edit modals */} {/* Create and edit modals */}
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />} {showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />} {editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
{/* The delete is shared: the link goes for every member, and on the phone the
button is a thumb's width from pin. Same guard as the notes panel. */}
<ConfirmDialog
isOpen={pendingDeleteId !== null}
onClose={() => setPendingDeleteId(null)}
onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)}
title={t('collab.links.confirmDeleteTitle')}
message={t('collab.links.confirmDeleteBody')}
/>
</div> </div>
) )
} }
@@ -201,7 +201,9 @@ describe('CollabNotes', () => {
// Deleting now asks for confirmation first — the note stays until confirmed. // Deleting now asks for confirmation first — the note stays until confirmed.
expect(screen.getByText('Delete note?')).toBeInTheDocument(); expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument(); expect(screen.getByText('Remove Me')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement); // The confirm button of the question itself, found by its role rather than a colour class.
const question = screen.getByText('Delete note?').closest('.trek-modal-enter') as HTMLElement;
await user.click(within(question).getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
}); });
@@ -1,4 +1,4 @@
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-022 // FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-023
/** /**
* The document-sync dialog shell (#2391). * The document-sync dialog shell (#2391).
@@ -49,6 +49,27 @@ vi.mock('../../../api/client', async (importOriginal) => {
} }
}) })
/** Set by the one test that needs a render slower than React's time slice. */
let slowCard = false
vi.mock('./DocSyncBinding', async (importOriginal) => {
const actual = await importOriginal<typeof import('./DocSyncBinding')>()
const Card = actual.default
return {
...actual,
default: function SlowableBinding(props: Parameters<typeof Card>[0]) {
// Busy for longer than the scheduler's 5 ms slice, so the commit that
// shows the card leaves its effects for a later task, as a loaded CI
// runner does.
if (slowCard) {
const end = performance.now() + 20
while (performance.now() < end) { /* busy */ }
}
return <Card {...props} />
},
}
})
import DocSyncPanel from './DocSyncPanel' import DocSyncPanel from './DocSyncPanel'
import type { DocSyncLink, DocSyncProvider } from './useDocSync' import type { DocSyncLink, DocSyncProvider } from './useDocSync'
@@ -142,6 +163,7 @@ const confirmUnlink = () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
slowCard = false
serverHas({}) serverHas({})
deleteLink.mockResolvedValue({}) deleteLink.mockResolvedValue({})
updateLink.mockResolvedValue({}) updateLink.mockResolvedValue({})
@@ -301,6 +323,39 @@ describe('DocSyncPanel: the store list', () => {
expect(second).toHaveAttribute('aria-current', 'true') expect(second).toHaveAttribute('aria-current', 'true')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current') expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current')
}) })
it('FE-DOCSYNC-PANEL-023: a store picked before the list-following effect has run stays picked', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
slowCard = true
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
// Outside act, as during the wait inside findBy*: React commits the card on
// its own scheduler, and the click lands the moment the card is in the DOM,
// before the effect that follows the list has had its turn.
const env = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
const previous = env.IS_REACT_ACT_ENVIRONMENT
env.IS_REACT_ACT_ENVIRONMENT = false
try {
await new Promise<void>(resolve => {
const observer = new MutationObserver(() => {
if (!document.querySelector('article')) return
observer.disconnect()
fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ }))
resolve()
})
observer.observe(document.body, { childList: true, subtree: true })
})
} finally {
env.IS_REACT_ACT_ENVIRONMENT = previous
}
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument()
})
}) })
describe('DocSyncPanel: a member rather than the owner', () => { describe('DocSyncPanel: a member rather than the owner', () => {
@@ -54,11 +54,12 @@ export default function DocSyncPanel({
const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined
// Follow the list: a freshly bound store should be the one on screen, and a // Follow the list: a freshly bound store should be the one on screen, and a
// removed one must not leave the detail column pointing at nothing. // removed one must not leave the detail column pointing at nothing. The pick
// is read when the update runs, not from this render: a store clicked before
// the effect got its turn was otherwise switched straight back.
useEffect(() => { useEffect(() => {
if (sync.links.length === 0) setSelected(null) setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id) }, [sync.links])
}, [sync.links, selected])
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0) const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
@@ -1,4 +1,4 @@
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040 // FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw' import { http, HttpResponse, delay } from 'msw'
@@ -1045,4 +1045,30 @@ describe('EntryEditor', () => {
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument() expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument() expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument()
}) })
it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => {
const user = userEvent.setup()
const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => {
// The /original of a video is the video file, which an <img> cannot draw.
const user = userEvent.setup()
const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' }
const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' }
const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
for (const id of [100, 200]) {
const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement
fireEvent.error(img)
expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`)
}
})
}) })
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef } from 'react' import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react'
import { localIsoDate } from '../../utils/localDate' import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } 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'
@@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn
type PendingProviderGroup = ProviderPhotoGroup & { provider: string } type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
// A photo whose thumbnail is missing can still be drawn from its original. A
// clip's original is the video file, which no <img> can show, so a clip gets
// no fallback (#2341).
function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) {
if (p.media_type === 'video') return undefined
return (e: SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
if (!img.src.includes('/original')) img.src = photoUrl(p, 'original')
}
}
// No poster to show and falling back to /original would hand an <img> the clip
// itself, so this tile stays a play badge.
function ClipTile() {
return (
<div className="absolute inset-0 bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
)
}
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: { export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
entry: JourneyEntry entry: JourneyEntry
journeyId: number journeyId: number
@@ -434,7 +455,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all" className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }} style={{ paddingTop: '100%' }}
> >
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> {posterlessVideo(gp) ? (
<ClipTile />
) : (
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={thumbnailFallback(gp)} />
)}
</button> </button>
))} ))}
{availableGalleryPhotos.length === 0 && ( {availableGalleryPhotos.length === 0 && (
@@ -528,13 +553,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{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' : ''}`}>
{posterlessVideo(p) ? ( {posterlessVideo(p) ? (
// No poster to show and falling back to /original would hand <ClipTile />
// 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 }} /> <img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={thumbnailFallback(p)} />
)} )}
{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>
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-008 // FE-JRN-COVER-001 to FE-JRN-COVER-010
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
@@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => {
expect(onClick).toHaveBeenCalledOnce() expect(onClick).toHaveBeenCalledOnce()
}) })
it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => {
// Its thumbnail route 404s on purpose, so an <img> would have been a broken face
// across the whole card.
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
expect(container.querySelector('svg.lucide-play')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-map-pin')).toBeNull()
})
it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => {
const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] })
const { container } = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
}) })
@@ -1,6 +1,7 @@
import { MapPin, Plus } from 'lucide-react' import { MapPin, Play, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta' import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import CountryFlag from '../shared/CountryFlag' import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard' import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore' import type { JourneyEntry } from '../../store/journeyStore'
@@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore'
* which needs no tokens at all. * which needs no tokens at all.
*/ */
/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */
export interface CoverPhoto extends CardPhoto {
media_type?: string | null
provider?: string | null
thumbnail_path?: string | null
}
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */ /** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
export interface CoverEntry { export interface CoverEntry {
id: number id: number
@@ -39,7 +47,7 @@ export interface CoverEntry {
entry_time?: string | null entry_time?: string | null
mood?: string | null mood?: string | null
weather?: string | null weather?: string | null
photos?: CardPhoto[] photos?: CoverPhoto[]
} }
interface Props { interface Props {
@@ -69,8 +77,12 @@ export default function JourneyEntryCover({
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton' const isSuggestion = entry.type === 'skeleton'
const photoId = cardPhotoId(entry.photos?.[0]) const first = entry.photos?.[0]
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail` const photoId = cardPhotoId(first)
// A clip without a poster has no picture to fill the card with; its thumbnail
// route 404s and an <img> pointed at it would draw the broken glyph (#2341).
const posterless = first != null && posterlessVideo(first)
const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const title = cardTitle(entry, t) const title = cardTitle(entry, t)
const place = cardPlace(entry) const place = cardPlace(entry)
@@ -112,7 +124,9 @@ export default function JourneyEntryCover({
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" /> <img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
) : ( ) : (
<span className="absolute inset-0 flex items-center justify-center"> <span className="absolute inset-0 flex items-center justify-center">
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" /> {posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
</span> </span>
)} )}
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017 // FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -234,4 +234,25 @@ describe('MobileEntryView', () => {
expect(screen.queryByText('Amazing')).not.toBeInTheDocument() expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
expect(screen.queryByText('Sunny')).not.toBeInTheDocument() expect(screen.queryByText('Sunny')).not.toBeInTheDocument()
}) })
it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => {
// Its original is the video file, which an <img> cannot show, so the hero used
// to be a broken image for every clip, poster or not.
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => {
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null }
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
const images = container.querySelectorAll('img')
// The second photo's strip thumbnail is the only image left.
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail')
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
})
}) })
@@ -7,6 +7,7 @@ import {
import JournalBody from './JournalBody' import JournalBody from './JournalBody'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters' import { formatLocationName } from '../../utils/formatters'
import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// Labels are translation keys, like the desktop tables in // Labels are translation keys, like the desktop tables in
@@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`} }`}
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
> >
<img {/* A clip is drawn from its poster, its original being the video file.
src={photoUrl(photos[0], 'original', publicPhotoUrl)} Without a poster the black ground and the play badge are the hero (#2341). */}
alt="" {posterlessVideo(photos[0]) ? (
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover' <span className="block aspect-video w-full max-h-[50vh]" />
}`} ) : (
/> <img
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/>
)}
{photos[0].media_type === 'video' && ( {photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
}`} }`}
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
> >
<img {!posterlessVideo(p) && (
src={photoUrl(p, 'thumbnail', publicPhotoUrl)} <img
alt="" src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover' alt=""
}`} className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
/> }`}
/>
)}
{p.media_type === 'video' && ( {p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -3,10 +3,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const plyrCtor = vi.fn() const plyrCtor = vi.fn()
vi.mock('plyr', () => ({ vi.mock('plyr', () => ({
default: class { default: class {
el: HTMLElement
original: Node
constructor(el: HTMLElement, options: Record<string, unknown>) { constructor(el: HTMLElement, options: Record<string, unknown>) {
plyrCtor(el, options) plyrCtor(el, options)
this.el = el
this.original = el.cloneNode(true)
}
// What the real destroy() does to the document: the element as it was at
// construction goes back in, whatever React wrote to it since.
destroy() {
this.el.replaceWith(this.original)
} }
destroy = vi.fn()
}, },
})) }))
vi.mock('plyr/dist/plyr.css', () => ({})) vi.mock('plyr/dist/plyr.css', () => ({}))
@@ -31,7 +39,7 @@ describe('VideoPlayer', () => {
it('resets the element to an empty source on teardown rather than a clip on the CDN', () => { 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 // media-src refuses cdn.plyr.io too; an empty source aborts the stream just the
// same, and the element is unmounted in the same tick. // same, and the element is out of the document by then.
render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />) render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
const [, options] = plyrCtor.mock.calls[0] const [, options] = plyrCtor.mock.calls[0]
@@ -45,4 +53,19 @@ describe('VideoPlayer', () => {
expect(el).toBe(container.querySelector('video')) expect(el).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail') expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail')
}) })
it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => {
// Rebuilding Plyr on the same <video> ran after React had already pointed it at
// the next clip, and destroy() then swapped the first clip back in. The file
// lightboxes do not key the player themselves, so this has to hold here.
const { container, rerender } = render(<VideoPlayer src="/api/photos/7/original" autoPlay={false} />)
rerender(<VideoPlayer src="/api/photos/8/original" autoPlay={false} />)
expect(plyrCtor).toHaveBeenCalledTimes(2)
const [first] = plyrCtor.mock.calls[0]
const [second] = plyrCtor.mock.calls[1]
expect(second).not.toBe(first)
expect(second).toBe(container.querySelector('video'))
expect(container.querySelector('video')).toHaveAttribute('src', '/api/photos/8/original')
})
}) })
+20 -14
View File
@@ -7,24 +7,30 @@ import 'plyr/dist/plyr.css'
// host and would fetch a data URL over XHR, which connect-src refuses as well. // host and would fetch a data URL over XHR, which connect-src refuses as well.
import plyrSprite from 'plyr/dist/plyr.svg?no-inline' import plyrSprite from 'plyr/dist/plyr.svg?no-inline'
interface Props {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}
/** /**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native * Video player for gallery/lightbox playback (#823), built on Plyr over a native
* <video>. Local videos stream with HTTP Range (seeking works out of the box) and * <video>. Local videos stream with HTTP Range (seeking works out of the box) and
* the source carries the correct video MIME from the server. The Plyr instance is * the source carries the correct video MIME from the server. The Plyr instance is
* created once per mounted source and destroyed on unmount, so navigating away in * created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback. * the lightbox stops playback.
*
* A new source is a new element: Plyr's destroy() puts a clone of the element as
* it was at construction back into the document, so rebuilding the player on a
* <video> React had already pointed at the next clip left the first clip on
* screen under the next one's name.
*/ */
export default function VideoPlayer({ export default function VideoPlayer(props: Props): React.ReactElement {
src, return <PlyrVideo key={props.src} {...props} />
poster, }
autoPlay = true,
style, function PlyrVideo({ src, poster, autoPlay = true, style }: Props): React.ReactElement {
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => { useEffect(() => {
@@ -48,8 +54,8 @@ export default function VideoPlayer({
iconUrl: plyrSprite, iconUrl: plyrSprite,
// On teardown Plyr points the element at a blank clip to abort the stream, // 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 // 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 // aborts the stream just the same, and the element is out of the document
// tick, so the error it would otherwise report has nowhere to show. // by then, so the error it would otherwise report has nowhere to show.
blankVideo: '', blankVideo: '',
}) })
@@ -60,7 +66,7 @@ export default function VideoPlayer({
/* already torn down */ /* already torn down */
} }
} }
}, [src, autoPlay]) }, [autoPlay])
return ( return (
<div <div
@@ -0,0 +1,246 @@
/**
* Clicking a place pin after the map has been dragged (#2504).
*
* Nothing Leaflet-related is mocked here on purpose. The bug lived in the space between
* our drag wiring and Leaflet's own event routing: the pins swallow mousedown so a drag
* onto a day can start without the map sliding away, and Leaflet only forgets that the
* map was panned when it sees the next mousedown. A mocked react-leaflet, like the one
* MapView.test.tsx uses, has no such memory and kept passing throughout.
*
* The click itself has to keep taking Leaflet's route and the page's: it is what closes
* an open popup on the map, and an open context menu beside it.
*/
import React, { useState } from 'react'
import { flushSync } from 'react-dom'
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
import { render, waitFor, act } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }),
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
import L from 'leaflet'
import { MapView } from './MapView'
import { ContextMenu } from '../shared/ContextMenu'
// Every map this file builds, newest last, for the tests that reach past the component.
const maps: L.Map[] = []
L.Map.addInitHook(function (this: L.Map) { maps.push(this) })
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
const RASTER = 'https://tiles.example.test/{z}/{x}/{y}.png'
// Leaflet sizes the map from the container, which jsdom lays out at 0 x 0. The cluster
// group only draws pins inside the visible bounds, so the map needs a real size.
const sized = ['clientWidth', 'clientHeight'] as const
const originals = new Map<string, PropertyDescriptor | undefined>()
beforeAll(() => {
// The pans here are synthetic and instant. With inertia, Leaflet would carry them on in
// animation frames that can land after a test has already unmounted its map.
L.Map.mergeOptions({ inertia: false })
for (const key of sized) {
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key))
Object.defineProperty(HTMLElement.prototype, key, { configurable: true, get: () => (key === 'clientWidth' ? 1000 : 700) })
}
})
afterAll(() => {
L.Map.mergeOptions({ inertia: true })
for (const key of sized) {
const original = originals.get(key)
if (original) Object.defineProperty(HTMLElement.prototype, key, original)
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key]
}
})
afterEach(() => {
resetAllStores()
})
/** Leaflet only starts a pan for the primary button, which it reads off `which`. */
function mouse(type: string, x: number, y: number): MouseEvent {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: y })
Object.defineProperty(event, 'which', { value: 1 })
return event
}
/** Drag the map by its background, the way a user pans it. */
function panMap(container: Element) {
act(() => {
container.dispatchEvent(mouse('mousedown', 300, 300))
container.dispatchEvent(mouse('mousemove', 340, 320))
container.dispatchEvent(mouse('mousemove', 380, 340))
container.dispatchEvent(mouse('mouseup', 380, 340))
})
}
/** A plain click on an element: press, release, click, all on the same spot. */
function clickOn(el: Element) {
act(() => {
el.dispatchEvent(mouse('mousedown', 500, 350))
el.dispatchEvent(mouse('mouseup', 500, 350))
el.dispatchEvent(mouse('click', 500, 350))
})
}
// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster.
const places = [
{ ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null },
{ ...buildPlace({ id: 2, name: 'Kasuga Taisha', lat: 34.6812, lng: 135.8484 }), category_name: null, category_color: null, category_icon: null },
]
async function renderMap() {
const onMarkerClick = vi.fn()
const onMapClick = vi.fn()
const view = render(
<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} onMapClick={onMapClick} />,
)
const container = view.container.querySelector('.leaflet-container') as HTMLElement
// The cluster group takes its markers in on a timer (chunkedLoading).
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
const pins = [...container.querySelectorAll('.leaflet-marker-icon[draggable="true"]')] as HTMLElement[]
return { container, pins, onMarkerClick, onMapClick }
}
describe('MapView on a real Leaflet map', () => {
it('FE-MAPCLICK-001: a pin opens its place on a map nobody has dragged yet', async () => {
const { pins, onMarkerClick, onMapClick } = await renderMap()
clickOn(pins[0])
expect(onMarkerClick).toHaveBeenCalledTimes(1)
// Still the marker's click, not one on the empty map behind it.
expect(onMapClick).not.toHaveBeenCalled()
})
it('FE-MAPCLICK-002: a pin still opens its place after the map was dragged (#2504)', async () => {
const { container, pins, onMarkerClick, onMapClick } = await renderMap()
panMap(container)
clickOn(pins[0])
expect(onMarkerClick).toHaveBeenCalledTimes(1)
// And again: the report was that every click after the pan went nowhere.
clickOn(pins[1])
expect(onMarkerClick).toHaveBeenCalledTimes(2)
expect(onMarkerClick.mock.calls.map(call => call[0]).sort()).toEqual([1, 2])
expect(onMapClick).not.toHaveBeenCalled()
})
it('FE-MAPCLICK-003: the click that ends a pan still counts for nothing', async () => {
const { container, onMarkerClick, onMapClick } = await renderMap()
panMap(container)
// The browser aims the click that closes a drag at the element both ends share,
// which is the map rather than the pin the pointer happened to stop on. Leaflet
// swallows it, and must go on doing so: it is not a click on the empty map either.
act(() => { container.dispatchEvent(mouse('click', 380, 340)) })
expect(onMarkerClick).not.toHaveBeenCalled()
expect(onMapClick).not.toHaveBeenCalled()
})
it("FE-MAPCLICK-004: a pin on a touch screen keeps Leaflet's own click and no drag wiring", async () => {
const original = Object.getOwnPropertyDescriptor(window.navigator, 'maxTouchPoints')
Object.defineProperty(window.navigator, 'maxTouchPoints', { configurable: true, value: 5 })
try {
const onMarkerClick = vi.fn()
const view = render(<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} />)
const container = view.container.querySelector('.leaflet-container') as HTMLElement
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon').length).toBe(2))
const pin = container.querySelector('.leaflet-marker-icon') as HTMLElement
expect(pin.getAttribute('draggable')).toBeNull()
clickOn(pin)
expect(onMarkerClick).toHaveBeenCalledTimes(1)
} finally {
if (original) Object.defineProperty(window.navigator, 'maxTouchPoints', original)
else delete (window.navigator as unknown as Record<string, unknown>).maxTouchPoints
}
})
it('FE-MAPCLICK-005: the place a pin opens stays open when the pin redraws mid-click', async () => {
// The selection a pin makes redraws its icon, which replaces the element the click
// landed on. Leaflet has to have found the pin before that happens: reading the target
// afterwards, it finds it cut out of the map and takes the click for one on the empty
// background, which would close the place again straight away.
const onMapClick = vi.fn()
function Planner() {
const [selected, setSelected] = useState<number | null>(null)
return (
<>
<output data-testid="selected">{selected ?? ''}</output>
<MapView
places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER}
selectedPlaceId={selected}
onMarkerClick={(id: number) => flushSync(() => setSelected(id))}
onMapClick={() => { onMapClick(); setSelected(null) }}
/>
</>
)
}
const view = render(<Planner />)
const container = view.container.querySelector('.leaflet-container') as HTMLElement
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement
// The pointer lands on the photo inside the pin, which the redraw replaces.
clickOn(pin.firstElementChild as HTMLElement)
expect(onMapClick).not.toHaveBeenCalled()
expect(view.getByTestId('selected').textContent).not.toBe('')
})
it('FE-MAPCLICK-006: a pin click after a pan still closes an open popup on the map', async () => {
const { container, pins, onMarkerClick } = await renderMap()
const map = maps[maps.length - 1]
panMap(container)
// A hazard or plugin popup, as the planner opens them. Leaflet closes those on the
// "preclick" it sends ahead of every click it routes, a pin's included.
const popup = L.popup({ autoPan: false }).setLatLng([34.685, 135.844]).setContent('Road closed')
act(() => { popup.openOn(map) })
expect(map.hasLayer(popup)).toBe(true)
clickOn(pins[0])
expect(onMarkerClick).toHaveBeenCalledTimes(1)
expect(map.hasLayer(popup)).toBe(false)
})
it('FE-MAPCLICK-007: a pin click after a pan still reaches the page and closes an open context menu', async () => {
const onClose = vi.fn()
const onMarkerClick = vi.fn()
const view = render(
<>
{/* The menu the places list and the day plan open on a right click. It closes on
any click that reaches the document. */}
<ContextMenu menu={{ x: 20, y: 20, items: [{ label: 'Edit' }] }} onClose={onClose} />
<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} />
</>,
)
const container = view.container.querySelector('.leaflet-container') as HTMLElement
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement
panMap(container)
clickOn(pin)
expect(onMarkerClick).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MAPCLICK-008: pressing a pin after a pan still holds the map still for a drag onto a day', async () => {
const { container, pins } = await renderMap()
const map = maps[maps.length - 1]
panMap(container)
const before = map.getCenter()
// Press on the pin and move off it, as the start of a drag towards the day plan.
act(() => {
pins[0].dispatchEvent(mouse('mousedown', 500, 350))
document.dispatchEvent(mouse('mousemove', 560, 390))
document.dispatchEvent(mouse('mousemove', 620, 430))
document.dispatchEvent(mouse('mouseup', 620, 430))
})
expect(map.getCenter()).toEqual(before)
// The pan handler is back on, and the background still pans the map.
expect(map.dragging.enabled()).toBe(true)
panMap(container)
expect(map.getCenter()).not.toEqual(before)
})
})
@@ -599,6 +599,32 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0) expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
}) })
it('FE-COMP-MAPVIEW-082: a booking switched on for another day keeps drawing unless the map is held to the day', () => {
// The desktop passes no scope, so a booking switched on stays drawn on every day.
// Only a caller that asks for it drops the booking from a day it does not run on.
const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
const reservation = buildReservation({
id: 44,
type: 'flight',
day_id: 10,
end_day_id: 10,
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
})
const reservations = [reservation]
const ids = [44]
const { rerender } = render(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} />)
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} scopeConnectionsToDay />)
expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={10} scopeConnectionsToDay />)
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-073: a category icon that throws leaves the marker circle intact', () => { it('FE-COMP-MAPVIEW-073: a category icon that throws leaves the marker circle intact', () => {
// Icon components come from a lookup table a plugin can extend; one that // Icon components come from a lookup table a plugin can extend; one that
// throws must not take the whole map down, only its own glyph. // throws must not take the whole map down, only its own glyph.
@@ -920,6 +946,14 @@ describe('MapView selection panning (#921)', () => {
expect(mapMock.panTo).toHaveBeenCalledWith([48, 2], { animate: true }) expect(mapMock.panTo).toHaveBeenCalledWith([48, 2], { animate: true })
}) })
it('FE-COMP-MAPVIEW-083: pans to a selected stay whose pin a filter keeps off the map', () => {
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
const stay = buildMapPlace({ id: 11, lat: 50, lng: 4 })
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
rerender(<MapView places={places} selectedPlaceId={11} selectedPlace={stay} />)
expect(mapMock.panTo).toHaveBeenCalledWith([50, 4], { animate: true })
})
it('FE-COMP-MAPVIEW-053: a selected place without coordinates is not panned to', () => { it('FE-COMP-MAPVIEW-053: a selected place without coordinates is not panned to', () => {
const places = [buildMapPlace({ id: 10, lat: null, lng: null })] const places = [buildMapPlace({ id: 10, lat: null, lng: null })]
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />) const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
@@ -1030,6 +1064,26 @@ describe('MapView live location', () => {
expect(geoMock.cycleMode).toHaveBeenCalled() expect(geoMock.cycleMode).toHaveBeenCalled()
}) })
it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
geoMock.mode = 'off'
render(<MapView />)
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
// landscape crosses the 768px cut without remounting the map
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
window.dispatchEvent(new Event('resize'))
})
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
})
it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => { it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
const desktop = render(<MapView />) const desktop = render(<MapView />)
// The desktop credit is Leaflet's own, in its own container; this component adds none. // The desktop credit is Leaflet's own, in its own container; this component adds none.
+46 -8
View File
@@ -330,10 +330,11 @@ interface SelectionControllerProps {
places: Place[] places: Place[]
selectedPlaceId: number | null selectedPlaceId: number | null
dayPlaces: Place[] dayPlaces: Place[]
selectedPlace: Place | null
paddingOpts: L.FitBoundsOptions paddingOpts: L.FitBoundsOptions
} }
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) { function SelectionController({ places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap() const map = useMap()
const prev = useRef(null) const prev = useRef(null)
@@ -343,7 +344,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
// side-panel + bottom-inspector padding so the pin lands in the middle of the // side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel // *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses. // would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = places.find(p => p.id === selectedPlaceId) const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) { if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng] const latlng: [number, number] = [selected.lat, selected.lng]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
@@ -357,7 +358,7 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
} }
} }
prev.current = selectedPlaceId prev.current = selectedPlaceId
}, [selectedPlaceId, places, map]) }, [selectedPlaceId, places, dayPlaces, selectedPlace, map])
return null return null
} }
@@ -547,11 +548,13 @@ import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } f
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
// Live-location rendering inside the Leaflet map. Subscribes via the // Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave // shared useGeolocation hook so the Leaflet and Mapbox variants behave
// identically. Heading is shown as a rotated conic SVG when available. // identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation' import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) { function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap() const map = useMap()
@@ -619,6 +622,32 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null
) )
} }
/**
* Make a place pin a drag source without losing its clicks to an old pan.
*
* The drag wiring swallows mousedown so the map holds still while a drag starts. Leaflet
* tells a click from the end of a pan by a flag it only clears on the next mousedown it
* sees, so once the map had been dragged, every click on a pin was taken for the tail of
* that pan and dropped, until something else on the map was pressed (#2504). A press on
* the pin now clears that flag itself, as the map would have. Switching the pan handler
* off and on again is the public way to do that, and nothing is being panned at that
* moment. The click then takes Leaflet's normal route, so it still closes open popups
* and still reaches listeners further up the page, like an open context menu.
*/
function wireDraggablePin(el: HTMLElement, placeId: number, map: L.Map): () => void {
const undoDrag = makeMarkerDraggable(el, placeId)
const forgetPan = (e: MouseEvent) => {
if (e.button !== 0 || !map.dragging.enabled()) return
map.dragging.disable()
map.dragging.enable()
}
el.addEventListener('mousedown', forgetPan)
return () => {
undoDrag()
el.removeEventListener('mousedown', forgetPan)
}
}
interface MemoMarkerProps { interface MemoMarkerProps {
place: any place: any
isSelected: boolean isSelected: boolean
@@ -637,6 +666,7 @@ const MemoMarker = memo(function MemoMarker({
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable, onRegister, place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable, onRegister,
}: MemoMarkerProps) { }: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected) const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
const map = useMap()
const cleanupRef = useRef<(() => void) | null>(null) const cleanupRef = useRef<(() => void) | null>(null)
// react-leaflet compares `position` by reference and calls setLatLng whenever it // react-leaflet compares `position` by reference and calls setLatLng whenever it
// differs, and the cluster group answers a moved child by taking it out and putting // differs, and the cluster group answers a moved child by taking it out and putting
@@ -655,7 +685,7 @@ const MemoMarker = memo(function MemoMarker({
// so the wiring is redone on every add rather than once on mount. // so the wiring is redone on every add rather than once on mount.
add: (e: any) => { add: (e: any) => {
cleanupRef.current?.() cleanupRef.current?.()
cleanupRef.current = draggable ? makeMarkerDraggable(e.target.getElement() as HTMLElement, place.id) : null cleanupRef.current = draggable ? wireDraggablePin(e.target.getElement() as HTMLElement, place.id, map) : null
}, },
remove: () => { cleanupRef.current?.(); cleanupRef.current = null }, remove: () => { cleanupRef.current?.(); cleanupRef.current = null },
click: () => onClickPlace(place.id), click: () => onClickPlace(place.id),
@@ -677,6 +707,8 @@ export const MapView = memo(function MapView({
routeColors = null, routeColors = null,
routeSegments = [], routeSegments = [],
selectedPlaceId = null, selectedPlaceId = null,
// The selected place itself, for when no pin on this map stands for it.
selectedPlace = null,
hoverDisabled = false, hoverDisabled = false,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
@@ -698,6 +730,9 @@ export const MapView = memo(function MapView({
showTransitRoutes = true, showTransitRoutes = true,
days = [] as Day[], days = [] as Day[],
selectedDayId = null, selectedDayId = null,
// Whether a booking switched on by hand also has to run on the selected day to be
// drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
scopeConnectionsToDay = false,
onReservationClick, onReservationClick,
pois = [] as Poi[], pois = [] as Poi[],
onPoiClick, onPoiClick,
@@ -761,8 +796,8 @@ export const MapView = memo(function MapView({
</Marker> </Marker>
)), [pois, onPoiClick, onPoiDropOnRoute]) )), [pois, onPoiClick, onPoiDropOnRoute])
const visibleReservations = useMemo(() => ( const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days }) visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days]) ), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails). // Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations) const transportRoutes = useTransportRoutes(visibleReservations)
// Dynamic padding: account for sidebars + bottom inspector + day detail panel // Dynamic padding: account for sidebars + bottom inspector + day detail panel
@@ -1033,7 +1068,10 @@ export const MapView = memo(function MapView({
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation() const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy), // Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it. // so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 // The width is followed rather than read once: a phone turned sideways and
// back crosses the breakpoint twice, and the button used to stay with
// whatever the map saw when it mounted.
const isMobile = useIsPhone()
// When the day-detail panel is open it slides up over the map (bottom: navh+20, // When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button // height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348 // above it; otherwise keep the plain bottom-nav offset. #1348
@@ -1123,7 +1161,7 @@ export const MapView = memo(function MapView({
<MapController center={center} zoom={zoom} /> <MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} /> <BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} /> <MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} /> <CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
@@ -1379,6 +1379,33 @@ describe('MapViewGL', () => {
expect(onReservationClick).toHaveBeenCalledWith(77) expect(onReservationClick).toHaveBeenCalledWith(77)
}) })
it('FE-COMP-MAPVIEWGL-087: a booking switched on for another day reaches the overlay unless the map is held to the day', async () => {
loadOnAttach()
const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
const flight = {
id: 44, trip_id: 1, type: 'flight', title: 'A → B', status: 'confirmed', day_id: 10, end_day_id: 10,
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
],
}
const reservations = [flight]
const ids = [44]
const drawn = () => (reservationOverlay.update.mock.lastCall?.[0] as { id: number }[]).map(r => r.id)
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} />,
)
await act(async () => {})
expect(drawn()).toEqual([44])
rerender(
<MapViewGL places={[]} fitKey={1} reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} scopeConnectionsToDay />,
)
await act(async () => {})
expect(drawn()).toEqual([])
})
it('FE-COMP-MAPVIEWGL-042: explore POIs get their own pin, hover popup and click callback', async () => { it('FE-COMP-MAPVIEWGL-042: explore POIs get their own pin, hover popup and click callback', async () => {
loadOnAttach() loadOnAttach()
const basePoi: Poi = { const basePoi: Poi = {
+20 -6
View File
@@ -22,6 +22,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor' import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types' import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover' import type { MapHoverInfo } from './mapHover'
@@ -41,6 +42,7 @@ import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport' import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
function categoryIconSvg(iconName: string | null | undefined, size: number): string { function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -146,6 +148,8 @@ interface Props {
routeColors?: ({ line: string; casing: string } | undefined)[] | null routeColors?: ({ line: string; casing: string } | undefined)[] | null
routeSegments?: RouteSegment[] routeSegments?: RouteSegment[]
selectedPlaceId?: number | null selectedPlaceId?: number | null
/** The selected place itself, for when no pin on this map stands for it. */
selectedPlace?: Place | null
onMarkerClick?: (id: number) => void onMarkerClick?: (id: number) => void
hoverDisabled?: boolean hoverDisabled?: boolean
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
@@ -163,6 +167,11 @@ interface Props {
showTransitRoutes?: boolean showTransitRoutes?: boolean
days?: Day[] days?: Day[]
selectedDayId?: number | null selectedDayId?: number | null
/**
* Whether a booking switched on by hand also has to run on the selected day to be
* drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
*/
scopeConnectionsToDay?: boolean
showReservationStats?: boolean showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void onReservationClick?: (reservationId: number) => void
pois?: Poi[] pois?: Poi[]
@@ -670,6 +679,7 @@ export function MapViewGL({
routeColors = null, routeColors = null,
routeSegments = NO_ROUTE_SEGMENTS, routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null, selectedPlaceId = null,
selectedPlace = null,
hoverDisabled = false, hoverDisabled = false,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
@@ -694,6 +704,7 @@ export function MapViewGL({
showTransitRoutes = true, showTransitRoutes = true,
days = NO_DAYS, days = NO_DAYS,
selectedDayId = null, selectedDayId = null,
scopeConnectionsToDay = false,
showReservationStats = false, showReservationStats = false,
onReservationClick, onReservationClick,
pois = NO_POIS, pois = NO_POIS,
@@ -1159,6 +1170,12 @@ export function MapViewGL({
const onMapReadyRef = useRef(onMapReady) const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation() const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy), so
// the location button would be misleading; the phone, where real GPS lives,
// keeps it. Read here with the other hooks rather than beside the button:
// the tokenless branch below returns early, and a hook after it runs on some
// renders and not others.
const isMobile = useIsPhone()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu }) const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick onClickRefs.current.map = onMapClick
@@ -2179,8 +2196,8 @@ export function MapViewGL({
// DayPlanSidebar — nothing is rendered until the user enables a // DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour. // booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => ( const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days }) visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days]) ), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails). // Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations) const transportRoutes = useTransportRoutes(visibleReservations)
@@ -2295,7 +2312,7 @@ export function MapViewGL({
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
if (!map || !selectedPlaceId) return if (!map || !selectedPlaceId) return
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId) const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (!target?.lat || !target?.lng) return if (!target?.lat || !target?.lng) return
try { try {
map.flyTo({ map.flyTo({
@@ -2382,9 +2399,6 @@ export function MapViewGL({
) )
} }
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20, // When the day-detail panel is open it slides up over the map (bottom: navh+20,
// height var(--day-panel-h)) and covers the button's band, so lift the button // height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348 // above it; otherwise keep the plain bottom-nav offset. #1348
@@ -13,6 +13,10 @@ import {
parsePluginProfile, parsePluginProfile,
withHotelBookends, withHotelBookends,
calculateAlternatives, calculateAlternatives,
avoidedClasses,
routeEngineFor,
sameRoad,
furthestFrom,
} from './RouteCalculator' } from './RouteCalculator'
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org // Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
@@ -1049,3 +1053,196 @@ describe('turning round at a stop', () => {
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } })) useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
}) })
}) })
// ── a trip that avoids something ──────────────────────────────────────────────
/**
* The rail drives a trip with avoided classes through Valhalla, and everything that talks
* about one of its legs has to use the same engine. The offers used to come from OSRM with
* nothing avoided, so the picker described a different drive from the rail's; and an OSRM
* stand-in for a Valhalla that did not answer was filed as the avoided road for the rest
* of the session, without a word on the rail.
*/
describe('a trip that avoids something', () => {
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
/** Polyline6, the way Valhalla sends its shapes. */
const encode6 = (points: [number, number][]): string => {
const part = (value: number): string => {
let n = value < 0 ? ~(value << 1) : value << 1
let out = ''
while (n >= 0x20) { out += String.fromCharCode((0x20 | (n & 0x1f)) + 63); n >>= 5 }
return out + String.fromCharCode(n + 63)
}
let lat = 0
let lng = 0
return points.map(([pLat, pLng]) => {
const a = Math.round(pLat * 1e6)
const b = Math.round(pLng * 1e6)
const piece = part(a - lat) + part(b - lng)
lat = a
lng = b
return piece
}).join('')
}
const trip = (points: [number, number][], summary: Record<string, unknown> = {}) => ({
legs: [{ shape: encode6(points) }],
summary: { length: 100, time: 4000, has_toll: false, has_highway: false, has_ferry: false, ...summary },
})
const NORTH: [number, number][] = [[61, 10], [61.5, 11], [61, 12]]
const SOUTH: [number, number][] = [[61, 10], [60.5, 11], [61, 12]]
const DIRECT: [number, number][] = [[61, 10], [61, 11], [61, 12]]
const from = { lat: 61, lng: 10 }
const to = { lat: 61, lng: 12 }
it('FE-COMP-ROUTECALCULATOR-059: the ways come from Valhalla with the trip classes, and OSRM is never asked', async () => {
let osrm = 0
const bodies: Array<Record<string, unknown>> = []
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
http.post(VALHALLA, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({
trip: trip(DIRECT, { time: 3600 }),
alternates: [{ trip: trip(NORTH, { time: 4200, has_ferry: true }) }, { trip: trip(SOUTH, { time: 4800 }) }],
})
}),
)
const routes = await calculateAlternatives(from, to, 'driving', { avoid: ['toll'] })
expect(osrm).toBe(0)
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ alternates: 2, costing_options: { auto: { use_tolls: 0 } } })
expect(bodies[0].locations).toHaveLength(2)
expect(routes.map(r => r.engine)).toEqual(['valhalla', 'valhalla', 'valhalla'])
expect(routes.map(r => r.duration)).toEqual([3600, 4200, 4800])
expect(routes.map(r => r.hasFerry)).toEqual([false, true, false])
// Pinnable like any other offer: measured against the preferred one.
expect(routes[0].divergence).toBeNull()
expect(routes[1].divergence).toEqual({ lat: 61.5, lng: 11 })
})
it('FE-COMP-ROUTECALCULATOR-060: a Valhalla with nothing to say is the router not answering, not the OSRM roads', async () => {
let osrm = 0
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
http.post(VALHALLA, () => new HttpResponse(null, { status: 503 })),
)
await expect(calculateAlternatives({ lat: 62, lng: 10 }, { lat: 62, lng: 12 }, 'driving', { avoid: ['ferry'] })).rejects.toThrow()
expect(osrm).toBe(0)
})
it('FE-COMP-ROUTECALCULATOR-061: one way back is offered a second only for a class the trip does not avoid yet', async () => {
const asked: Array<Record<string, number>> = []
server.use(http.post(VALHALLA, async ({ request }) => {
const body = await request.json() as { alternates?: number; costing_options: { auto: Record<string, number> } }
asked.push(body.costing_options.auto)
if (body.alternates) return HttpResponse.json({ trip: trip(DIRECT, { has_toll: true }) })
// Asked to leave out the tolls as well: a road that really is without them.
return HttpResponse.json({ trip: trip(NORTH, { time: 5000, has_toll: false }) })
}))
const routes = await calculateAlternatives({ lat: 61, lng: 10.5 }, { lat: 61, lng: 12 }, 'driving', { avoid: ['motorway'] })
// Motorways are avoided already, so the first extra question is about tolls, and it
// keeps the trip's own class in.
expect(asked).toEqual([{ use_highways: 0 }, { use_highways: 0, use_tolls: 0 }])
expect(routes).toHaveLength(2)
expect(routes[1]).toMatchObject({ avoids: 'toll', engine: 'valhalla', duration: 5000 })
})
it('FE-COMP-ROUTECALCULATOR-062: only a driving leg is weighed; a walk is asked of OSRM as before', async () => {
let valhalla = 0
server.use(
http.get(`${FOSSGIS.walking}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 61], [12, 61]] }, distance: 1000, duration: 900 }] })),
http.post(VALHALLA, () => { valhalla++; return HttpResponse.json({ trip: trip(DIRECT) }) }),
)
const routes = await calculateAlternatives(from, to, 'walking', { avoid: ['toll'] })
expect(valhalla).toBe(0)
expect(routes).toHaveLength(1)
expect(routes[0].engine).toBeUndefined()
expect(avoidedClasses('walking', ['toll'])).toEqual([])
expect(avoidedClasses('driving', ['toll', 'ferry'])).toEqual(['ferry', 'toll'])
expect(routeEngineFor('driving', ['toll'])).toBe('valhalla')
expect(routeEngineFor('driving', [])).toBe('osrm')
expect(routeEngineFor('plugin:ev/fast', ['toll'])).toBe('plugin')
})
it('FE-COMP-ROUTECALCULATOR-063: an OSRM stand-in says nothing was avoided, and is not filed as the avoided road', async () => {
const [a, b] = freshWaypoints()
let osrm = 0
let valhallaUp = false
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildLegsResponse()) }),
http.post(VALHALLA, () => (valhallaUp
? HttpResponse.json({ trip: { legs: [{ shape: encode6([[a.lat, a.lng], [b.lat, b.lng]]), summary: { length: 5, time: 700, has_toll: false, has_ferry: true } }] } })
: new HttpResponse(null, { status: 503 }))),
)
const fallback = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
expect(osrm).toBe(1)
expect(fallback.avoidance).toEqual({ asked: ['toll'], achieved: [], fellBack: true })
expect(fallback.distance).toBe(4200)
// Valhalla is back: the same drive asks it again instead of keeping the OSRM answer.
valhallaUp = true
const weighed = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
expect(weighed.avoidance).toEqual({ asked: ['toll'], achieved: ['toll'] })
expect(weighed.distance).toBe(5000)
expect(weighed.hasFerry).toBe(true)
// The stand-in was filed where it is true: as the plain OSRM answer, which an ordinary
// caller now gets without asking again, and without the avoidance attached.
const plain = await calculateRouteWithLegs([a, b])
expect(osrm).toBe(1)
expect(plain.distance).toBe(4200)
expect(plain.avoidance).toBeUndefined()
})
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
// The shape of a leg bent by a via: every point of the plain road lies on it, so
// asked only one way round the two read as one.
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 }
expect(sameRoad(plainRoad, spur)).toBe(false)
expect(sameRoad(spur, plainRoad)).toBe(false)
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true)
})
it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
// exactly between those samples, 1.8 km from the nearest, on the very same road.
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
const sparse: [number, number][] = [
[50, 0],
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
[50, 10],
]
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
expect(sameRoad(a, b)).toBe(true)
const off = furthestFrom([[50.02, 5]], dense)
expect(off?.index).toBe(0)
expect(off?.km).toBeCloseTo(2.22, 1)
})
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
http.post(VALHALLA, () => HttpResponse.json({ trip: trip([[63, 10], [63.6, 11], [63, 12]], { has_highway: false, has_ferry: true }) })),
)
const routes = await calculateAlternatives({ lat: 63, lng: 10 }, { lat: 63, lng: 12 })
expect(routes).toHaveLength(2)
expect(routes[1]).toMatchObject({ avoids: 'motorway', engine: 'valhalla', hasFerry: true })
})
})
+227 -49
View File
@@ -4,7 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
import { haversineKm } from '../../utils/geo' import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units' import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter' import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute' import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import type { RouteUsageSurface } from '@trek/shared' import type { RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the // FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
@@ -216,6 +216,33 @@ export function parsePluginProfile(profile: string): { pluginId: string; profile
return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) } return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) }
} }
/**
* Which engine priced a route: OSRM, the second engine that weighs road classes away, or
* a route provider plugin. Their figures are three different speed models, so a figure
* from one read against a figure from another measures the models, not the roads.
*/
export type RouteEngine = 'osrm' | 'valhalla' | 'plugin'
/**
* The classes a request really weighs away, in a stable order, and none when it cannot.
*
* Only the driving profile has classes to leave out and only Valhalla can leave them out,
* so every other request is asked plainly whatever the trip says. One function for the
* day routing and for the offers beside it, so the two cannot disagree about which engine
* a leg belongs to.
*/
export function avoidedClasses(profile: RouteProfileKey, avoid: readonly AvoidClass[]): AvoidClass[] {
return profile === 'driving' && avoid.length > 0 && valhallaAvailable()
? [...avoid].sort((a, b) => a.localeCompare(b))
: []
}
/** The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to. */
export function routeEngineFor(profile: RouteProfileKey, avoid: readonly AvoidClass[]): RouteEngine {
if (parsePluginProfile(profile)) return 'plugin'
return avoidedClasses(profile, avoid).length ? 'valhalla' : 'osrm'
}
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */ /** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
export async function calculateRoute( export async function calculateRoute(
waypoints: Waypoint[], waypoints: Waypoint[],
@@ -480,9 +507,7 @@ export async function calculateRouteWithLegs(
return { coordinates: [], distance: 0, duration: 0, legs: [] } return { coordinates: [], distance: 0, duration: 0, legs: [] }
} }
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable() const avoiding = avoidedClasses(profile, avoid)
? [...avoid].sort((a, b) => a.localeCompare(b))
: []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is // The cached result carries formatted leg distances, so the active distance unit is
@@ -497,23 +522,30 @@ export async function calculateRouteWithLegs(
// way is worse: this Map is shared with the ordinary day planner, the studio and the // way is worse: this Map is shared with the ordinary day planner, the studio and the
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored // booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
// under the plain key would hand them a road nobody drives and a second engine's // under the plain key would hand them a road nobody drives and a second engine's
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key // times. So the avoiding key only ever holds a Valhalla answer: when Valhalla cannot
// also separates the two engines. // answer and OSRM drives the request after all, that answer is filed under the plain
// key, where it is exactly what an ordinary caller would have got. Filed under the
// avoiding key it stood in for the avoided road until the next reload, however soon
// Valhalla came back.
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : '' const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : '' const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}` const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
const cacheKey = `${plainKey}${avoidScope}`
const cached = routeCache.get(cacheKey) const cached = routeCache.get(cacheKey)
if (cached) return cached if (cached) return cached
if (avoiding.length) { if (avoiding.length) {
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal) const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
// day drawn rather than blank, and `avoidance` stays absent on that result so the
// rail can say the avoidance did not happen instead of implying it did.
if (routed) { if (routed) {
cacheRoute(cacheKey, routed) cacheRoute(cacheKey, routed)
return routed return routed
} }
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
// than blank, and the answer says that nothing was avoided, with the reason, so the
// rail flags the day instead of implying the setting held on a road nobody weighed.
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(plainKey, plain)
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
} }
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider // Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
@@ -558,6 +590,18 @@ export async function calculateRouteWithLegs(
return result return result
} }
const result = await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(cacheKey, result)
return result
}
/** The OSRM answer for a chain, one leg per waypoint pair. Throws like every OSRM call here. */
async function osrmWithLegs(
waypoints: Waypoint[],
coords: string,
profile: RouteProfileKey,
signal?: AbortSignal,
): Promise<RouteWithLegs> {
// Written as literals rather than narrowing `profile`: its type is an open string union // Written as literals rather than narrowing `profile`: its type is an open string union
// (plugins name their own modes), which no comparison narrows to the three OSRM knows. // (plugins name their own modes), which no comparison narrows to the three OSRM knows.
const osrmProfile: 'driving' | 'walking' | 'cycling' = const osrmProfile: 'driving' | 'walking' | 'cycling' =
@@ -592,9 +636,7 @@ export async function calculateRouteWithLegs(
) )
const snapped = readSnapped(data, waypoints) const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) } return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
cacheRoute(cacheKey, result)
return result
} }
/** Store a route and drop the oldest once the cache is over its cap. */ /** Store a route and drop the oldest once the cache is over its cap. */
@@ -659,6 +701,7 @@ async function routeAvoidingWithLegs(
legs, legs,
snapped: run.snapped, snapped: run.snapped,
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) }, avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
hasFerry: run.total.hasFerry,
} }
} }
@@ -714,7 +757,7 @@ function formatDuration(seconds: number): string {
return `${m} min` return `${m} min`
} }
/** One way of driving a leg, as OSRM offers it. */ /** One way of driving a leg, as a router offers it. */
export interface RouteAlternative { export interface RouteAlternative {
coordinates: [number, number][] coordinates: [number, number][]
distance: number distance: number
@@ -724,20 +767,24 @@ export interface RouteAlternative {
/** Set when this way exists because a road class was left out of it. */ /** Set when this way exists because a road class was left out of it. */
avoids?: AvoidClass avoids?: AvoidClass
/** /**
* Which engine priced this route, when it was not the one that priced the rest. * Which engine priced this route. Absent means OSRM, which is what answers every route
* of a trip that avoids nothing, except the avoidance offer on a default install.
* *
* Absent for everything OSRM answered, which is every route in a list except the * It exists because the engines do not agree on speed: measured over twelve European
* avoidance offer on a default install. It exists because the two engines do not * legs the per-leg drive times run from 14.7 % under OSRM's to 13.2 % over, and the
* agree on speed: measured over twelve European legs the per-leg drive times run * sign depends on the region: Valhalla is faster on Spanish autovía and slower through
* from 14.7 % under OSRM's to 13.2 % over, and the sign depends on the region: * a city. So a figure from one of them subtracted from a figure from the other is not a
* Valhalla is faster on Spanish autovía and slower through a city. So a figure from * difference in driving time, it is the gap between two speed models, and the reader
* one of them subtracted from a figure from the other is not a difference in * has no way of telling the two apart.
* driving time, it is the gap between two speed models, and the reader has no way
* of telling the two apart.
*/ */
engine?: 'valhalla' engine?: RouteEngine
/** Whether the way crosses by ferry, where the engine that drew it says so. */
hasFerry?: boolean
} }
/** What two routes are compared by: their line, and their figures where there is none. */
export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'duration'>
/** /**
* Road classes worth asking the router to leave out, in the order they are tried. * Road classes worth asking the router to leave out, in the order they are tried.
* *
@@ -780,10 +827,16 @@ const SAME_ROAD_KM = 0.5
* *
* The geometry does not care which engine drew it, so it decides whenever there is one. * The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare. * The numbers stay as the fallback for a line with no shape to compare.
*
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
* whole of `b` and then turns off to a point and back passed as `b`, because every point
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
* the check behind a choice both have to tell it from the plain road.
*/ */
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean { export function sameRoad(a: RoadLine, b: RoadLine): boolean {
const stray = furthestFrom(b.coordinates, a.coordinates) const there = furthestFrom(b.coordinates, a.coordinates)
if (stray) return stray.km < SAME_ROAD_KM const back = furthestFrom(a.coordinates, b.coordinates)
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60 return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
} }
@@ -862,12 +915,18 @@ async function valhallaExcluding(
// and every routing request an instance makes still lands in the one counter. // and every routing request an instance makes still lands in the one counter.
if (!signal?.aborted) countRoute({ ...sample, failed: !leg }) if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
if (!leg || !legAvoids(leg, exclude)) return null if (!leg || !legAvoids(leg, exclude)) return null
return valhallaOffer(leg)
}
/** A Valhalla leg as an offer, carrying the engine that priced it and what it crosses. */
function valhallaOffer(leg: ValhallaLeg): RouteAlternative {
return { return {
coordinates: leg.coordinates, coordinates: leg.coordinates,
distance: leg.distance, distance: leg.distance,
duration: leg.duration, duration: leg.duration,
divergence: null, divergence: null,
engine: 'valhalla', engine: 'valhalla',
hasFerry: leg.hasFerry,
} }
} }
@@ -917,12 +976,89 @@ async function osrmExcluding(
* It is what makes a choice persistable: saving that point as a via forces the router back * It is what makes a choice persistable: saving that point as a via forces the router back
* onto this road on every future request, without storing a polyline that would go stale * onto this road on every future request, without storing a polyline that would go stale
* with the next OSM update. * with the next OSM update.
*
* `avoid` is the classes the trip weighs away. A driving leg of such a trip is routed by
* Valhalla, so its offers come from Valhalla too, weighed the same way, and never from
* OSRM: a list holding both engines is what offered a road the rail was not on as the
* quicker one, and clicking it changed nothing.
*/ */
export async function calculateAlternatives( export async function calculateAlternatives(
from: Waypoint, from: Waypoint,
to: Waypoint, to: Waypoint,
profile: 'driving' | 'walking' | 'cycling' = 'driving', profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {}, { signal, limit = 3, avoid = [] }: { signal?: AbortSignal; limit?: number; avoid?: readonly AvoidClass[] } = {},
): Promise<RouteAlternative[]> {
const avoiding = avoidedClasses(profile, avoid)
const routes = avoiding.length
? await alternativesAvoiding(from, to, avoiding, limit, signal)
: await alternativesFromOsrm(from, to, profile, limit, signal)
if (!routes.length) return routes
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes
}
/**
* The offers for a leg of a trip that weighs classes away, every one of them Valhalla's.
*
* Throws when Valhalla has nothing to say, so the picker reports a router that is not
* answering. Quietly offering OSRM's roads instead is the very mix this path exists to end.
*
* One way back gets the question the OSRM path asks too: the same drive with one more
* class left out, only of those the trip does not already avoid, and only where the road
* that comes back really is without it. Paced like the pieces of a split day, because the
* public instance allows one request a second.
*/
async function alternativesAvoiding(
from: Waypoint,
to: Waypoint,
avoiding: AvoidClass[],
limit: number,
signal?: AbortSignal,
): Promise<RouteAlternative[]> {
const sample = {
profile: 'driving' as const,
surface: 'alternatives' as const,
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
waypoints: 2,
km: haversineKm(from, to),
}
const answered = await valhallaAlternates(from, to, 'driving', avoiding, Math.max(0, limit - 1), signal)
if (!signal?.aborted) countRoute({ ...sample, failed: !answered })
if (!answered?.length) throw new Error('No route found')
const routes: RouteAlternative[] = []
for (const leg of answered) {
const offer = valhallaOffer(leg)
if (!routes.some(r => sameRoad(r, offer))) routes.push(offer)
}
if (routes.length >= 2) return routes
for (const cls of EXCLUDABLE_CLASSES) {
if (avoiding.includes(cls)) continue
await valhallaSpacing(signal)
if (signal?.aborted) break
const run = await valhallaRun([from, to], 'driving', [...avoiding, cls], signal)
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
if (!run || !legAvoids(run.total, cls)) continue
const detour = valhallaOffer(run.total)
if (routes.some(r => sameRoad(r, detour))) continue
routes.push({ ...detour, avoids: cls })
break
}
return routes
}
/** The offers for a leg of a trip that avoids nothing: OSRM's, and one avoidance offer. */
async function alternativesFromOsrm(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
limit: number,
signal?: AbortSignal,
): Promise<RouteAlternative[]> { ): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}` const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson` const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
@@ -960,11 +1096,6 @@ export async function calculateAlternatives(
} }
} }
} }
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes return routes
} }
@@ -982,31 +1113,78 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a * nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in * real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland. * Andalusia than in Lapland.
*
* `index` is where on `line` the point sits, so points taken off one line can be put back
* in the order that line drives through them.
*/ */
function furthestFrom( export function furthestFrom(
line: [number, number][], line: [number, number][],
reference: [number, number][], reference: [number, number][],
): { lat: number; lng: number; km: number } | null { ): { index: number; lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a // Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds. // full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200)) const step = Math.max(1, Math.floor(reference.length / 200))
let best: { lat: number; lng: number } | null = null let bestIndex = -1
let bestNeighbour: [number, number] | null = null let bestNeighbour: [number, number] | null = null
let bestDist = -1 let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) { for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const [lat, lng] = line[i] const near = nearestOn(line[i], reference, step)
let nearest = Infinity if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
let nearestPoint: [number, number] | null = null
for (let j = 0; j < reference.length; j += step) {
const dLat = lat - reference[j][0]
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
const d = dLat * dLat + dLng * dLng
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
} }
if (!best) return null if (bestIndex < 0) return null
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0 const [lat, lng] = line[bestIndex]
return { ...best, km } const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { index: bestIndex, lat, lng, km }
}
/**
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
* degrees and the spot on the line it was measured to.
*
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
* on the very same road sat up to half a sample apart from it, which on a thousand
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
*/
function nearestOn(
point: [number, number],
reference: [number, number][],
step: number,
): { d: number; at: [number, number] } {
const cos = Math.cos((point[0] * Math.PI) / 180)
const squared = (p: [number, number]): number => {
const dLat = point[0] - p[0]
const dLng = (point[1] - p[1]) * cos
return dLat * dLat + dLng * dLng
}
let nearestIndex = 0
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const d = squared(reference[j])
if (d < nearest) { nearest = d; nearestIndex = j }
}
let at = reference[nearestIndex]
const last = Math.min(reference.length - 1, nearestIndex + step)
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
const d = squared(on)
if (d < nearest) { nearest = d; at = on }
}
return { d: nearest, at }
}
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
function closestOnSegment(
p: [number, number],
a: [number, number],
b: [number, number],
cos: number,
): [number, number] {
const dx = (b[1] - a[1]) * cos
const dy = b[0] - a[0]
const length = dx * dx + dy * dy
if (length === 0) return a
const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length))
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
} }
+79 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns } from './dayRoutePlan' import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types' import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
@@ -142,7 +142,85 @@ describe('buildDayRouteRuns', () => {
expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465]) expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465])
}) })
it('FE-MAP-DRP-015: the hotel points say which end of the day they are, a stop on the same spot does not (#2501)', () => {
const hotel = { id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
const runs = buildDayRouteRuns(2, inputs({
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })],
assignments: { '2': [at(48.86, 2.35, 0), at(48.80, 2.30, 1), at(48.88, 2.36, 2)] },
accommodations: [hotel],
optimizeFromAccommodation: true,
}))
const legs = runs.flatMap(run => run.slice(1).map((p, i) => hotelBookendOf(run[i], p) ?? null))
// Out of the hotel, three stops (one of them on the hotel's own spot), back to it.
expect(legs).toEqual(['morning', null, null, 'evening'])
})
it('FE-MAP-DRP-016: the one drive of a moving day without stops is its morning leg (#2476)', () => {
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
const stays = [
{ id: 1, trip_id: 1, place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 },
{ id: 2, trip_id: 1, place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 3 },
] as unknown as Accommodation[]
const [run] = buildDayRouteRuns(2, inputs({ days, accommodations: stays, optimizeFromAccommodation: true }))
expect(run.map(p => p.hotel)).toEqual(['morning', 'evening'])
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
})
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => { 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([])
}) })
describe('a moving day from one stay to the next (#2476)', () => {
// Day 3 checks out of a hotel in Munich and into one in Hamburg, with a flight
// in between. The booked Hamburg night wrote its own (hidden) stop onto day 3.
const HOTEL_A = { lat: 48.137, lng: 11.575 }
const HOTEL_B = { lat: 53.551, lng: 9.993 }
const MUC = { lat: 48.353, lng: 11.786 }
const HAM = { lat: 53.63, lng: 9.988 }
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
const stays = [
{ id: 1, trip_id: 1, place_lat: HOTEL_A.lat, place_lng: HOTEL_A.lng, start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00' },
{ id: 2, trip_id: 1, place_lat: HOTEL_B.lat, place_lng: HOTEL_B.lng, start_day_id: 3, end_day_id: 5, check_in: '15:00', check_out: '11:00' },
] as unknown as Accommodation[]
const bookedStop = buildAssignment({ day_id: 3, order_index: 0, accommodation_id: 2, place: buildPlace({ ...HOTEL_B }) })
const flight = (located: boolean) => ({
id: 7, trip_id: 1, type: 'flight', title: 'LH 2078', status: 'confirmed', day_id: 3, end_day_id: 3,
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20',
endpoints: located
? [{ role: 'from', sequence: 0, name: 'MUC', ...MUC }, { role: 'to', sequence: 1, name: 'HAM', ...HAM }]
: [],
}) as unknown as Reservation
const movingDay = (reservations: Reservation[], dayStops = [bookedStop]) =>
buildDayRouteRuns(3, inputs({
days, assignments: { '3': dayStops }, reservations, accommodations: stays, optimizeFromAccommodation: true,
})).map(run => run.map(p => [p.lat, p.lng]))
it('FE-MAP-DRP-011: a flight saved without airports draws no drive from one hotel to the other', () => {
// Nothing located is left on the day, and the flight is still the move: a road
// from Munich to Hamburg is exactly the trip that did not happen.
expect(movingDay([flight(false)])).toEqual([])
})
it('FE-MAP-DRP-012: with its airports the flight splits the day at them, as reported', () => {
expect(movingDay([flight(true)])).toEqual([
[[HOTEL_A.lat, HOTEL_A.lng], [MUC.lat, MUC.lng]],
[[HAM.lat, HAM.lng], [HOTEL_B.lat, HOTEL_B.lng]],
])
})
it('FE-MAP-DRP-013: without any booking the move is still the drive between the two hotels (#1297)', () => {
expect(movingDay([])).toEqual([[[HOTEL_A.lat, HOTEL_A.lng], [HOTEL_B.lat, HOTEL_B.lng]]])
})
it('FE-MAP-DRP-014: a hotel stop planned by hand ahead of the flight is reached after landing', () => {
// The traveller put the Hamburg hotel on the day before booking it, so the stop
// stays visible and carries no time. The flight still seats itself in front of
// the place it lands next to, and no road runs from Munich to Hamburg.
const handPlaced = at(HOTEL_B.lat, HOTEL_B.lng, 0, { day_id: 3 })
expect(movingDay([flight(true)], [handPlaced])).toEqual([
[[HOTEL_A.lat, HOTEL_A.lng], [MUC.lat, MUC.lng]],
[[HAM.lat, HAM.lng], [HOTEL_B.lat, HOTEL_B.lng]],
])
})
})
}) })
+28 -9
View File
@@ -1,7 +1,7 @@
import { withHotelBookends } from './RouteCalculator' import { withHotelBookends } from './RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge' import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder' import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '../../utils/geo' import { withinDriveRange } from '@trek/shared/roadtrip'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types' import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
@@ -13,6 +13,19 @@ export interface DayRoutePoint {
isPlace: boolean isPlace: boolean
leg_transport_mode?: string | null leg_transport_mode?: string | null
incoming_leg_transport_mode?: string | null incoming_leg_transport_mode?: string | null
/** Only on an accommodation bookend: the stay the day starts from or ends at. */
hotel?: 'morning' | 'evening'
}
/**
* Which accommodation bookend the leg between two neighbouring run points is, if any.
* A day that is nothing but the drive from one stay to the next counts as its morning
* leg, so it shows once at the top rather than at both ends (#2476).
*/
export function hotelBookendOf(from: DayRoutePoint, to: DayRoutePoint): 'morning' | 'evening' | undefined {
if (from.hotel === 'morning') return 'morning'
if (to.hotel === 'evening') return 'evening'
return undefined
} }
/** Everything the plan depends on, passed in rather than read from the store, so the /** Everything the plan depends on, passed in rather than read from the store, so the
@@ -131,9 +144,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
} }
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode // A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
// falls through to the day default for hotel-adjacent legs unless the place endpoint // falls through to the day default for hotel-adjacent legs unless the place endpoint
// carries its own override. // carries its own override. It says which end of the day it is, so the legs routed
const hotelPt = (a?: Accommodation): DayRoutePoint | null => // from it can be told apart from a stop planned on the hotel's own spot (#2501).
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null const hotelPt = (a: Accommodation | undefined, hotel: 'morning' | 'evening'): DayRoutePoint | null =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false, hotel } : null
// Only draw a hotel bookend when the leg is a real drive: a place before check-in // Only draw a hotel bookend when the leg is a real drive: a place before check-in
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on // (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
// an arrival/departure day (#1321, #2133) all draw no bookend. // an arrival/departure day (#1321, #2133) all draw no bookend.
@@ -154,8 +168,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId)) const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
const firstWay = flatPts[0] const firstWay = flatPts[0]
const lastWay = flatPts[flatPts.length - 1] const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning) const morningHotel = hotelPt(bookends?.morning, 'morning')
const eveningHotel = hotelPt(bookends?.evening) const eveningHotel = hotelPt(bookends?.evening, 'evening')
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier) const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay)) && (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier) const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
@@ -170,9 +184,14 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
// Transfer day with no activities: you check out of one accommodation and into // Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to. // another, so there are no waypoints for withHotelBookends to attach a leg to.
if (runsWithHotel.length === 0 && drawMorning && drawEvening) { // Not when a flight, train, ferry or coach is booked on the day, located or not:
const m = hotelPt(bookends?.morning) // that booking IS the move, and the road from one hotel to the other is exactly
const e = hotelPt(bookends?.evening) // the stretch nobody drove (#2476). One saved without its stations leaves no
// waypoint behind, so the gates above never see it; no line beats a wrong one.
const dayHasCarrierBooking = dayTransports.some(r => isCarrierTransport(r))
if (runsWithHotel.length === 0 && drawMorning && drawEvening && !dayHasCarrierBooking) {
const m = hotelPt(bookends?.morning, 'morning')
const e = hotelPt(bookends?.evening, 'evening')
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e]) if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
} }
+2
View File
@@ -35,6 +35,8 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
// The map pans on mousedown anywhere on its surface, and a marker sits on that // The map pans on mousedown anywhere on its surface, and a marker sits on that
// surface. Without this the map slides away under the pointer while the drag // surface. Without this the map slides away under the pointer while the drag
// is starting, which makes the day plan an impossible target to reach. // is starting, which makes the day plan an impossible target to reach.
// The map then never sees the press that starts a click on the marker, which is why the Leaflet
// renderer clears the map's pan flag itself when a pin is pressed (wireDraggablePin, #2504).
const onMouseDown = (e: MouseEvent) => e.stopPropagation() const onMouseDown = (e: MouseEvent) => e.stopPropagation()
el.addEventListener('dragstart', onDragStart) el.addEventListener('dragstart', onDragStart)
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { selectedPlaceTarget } from './selectedPlaceTarget'
// FE-MAP-SELTARGET-001 to FE-MAP-SELTARGET-004
describe('selectedPlaceTarget', () => {
const pin = { id: 1, lat: 48, lng: 2 }
const stop = { id: 2, lat: 49, lng: 3 }
const stay = { id: 3, lat: 50, lng: 4 }
it('FE-MAP-SELTARGET-001: goes to the pin the map shows', () => {
expect(selectedPlaceTarget(1, [pin], [stop], stay)).toBe(pin)
})
it('FE-MAP-SELTARGET-002: goes to the open day\'s stop when a filter hides its pin', () => {
expect(selectedPlaceTarget(2, [pin], [stop], null)).toBe(stop)
})
it('FE-MAP-SELTARGET-003: goes to a stay that is neither a pin nor a stop of the day', () => {
expect(selectedPlaceTarget(3, [pin], [stop], stay)).toBe(stay)
})
it('FE-MAP-SELTARGET-004: goes nowhere without a selection, or for a place it does not know', () => {
expect(selectedPlaceTarget(null, [pin], [stop], stay)).toBeNull()
expect(selectedPlaceTarget(9, [pin], [stop], stay)).toBeNull()
})
})
@@ -0,0 +1,27 @@
/** A place the map can go to. */
export interface MapTarget {
id: number
lat?: number | null
lng?: number | null
}
/**
* Where the map goes when a place is selected, the same in both renderers.
*
* Its pin first, then the open day's stop, then the selected place itself. A filter
* that hides the pin (Unplanned, a category) keeps the pin off the map but not the
* map away from the place, and a stay shown as a chip in the day list is neither a
* pin under Unplanned nor one of the day's stops, so a click on it used to leave the
* map where it was while a click on a stop moved it (#2488).
*/
export function selectedPlaceTarget<P extends MapTarget>(
selectedPlaceId: number | null | undefined,
places: P[],
dayPlaces: P[],
selectedPlace?: P | null,
): P | null {
if (!selectedPlaceId) return null
return places.find(p => p.id === selectedPlaceId)
?? dayPlaces.find(p => p.id === selectedPlaceId)
?? (selectedPlace?.id === selectedPlaceId ? selectedPlace : null)
}
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dayColor } from '../Roadtrip/dayColors' import { dayColor } from '../Roadtrip/dayColors'
import { dayRouteColor, planTripRoute, routeTrip, summariseTripRoute } from './tripRouteGeometry' import { dayRouteColor, planTripRoute, routeTrip, summariseTripRoute } from './tripRouteGeometry'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { AssignmentsMap, RouteSegment } from '../../types' import type { Accommodation, AssignmentsMap, Reservation, RouteSegment } from '../../types'
vi.mock('./RouteCalculator', async (importActual) => { vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>() const actual = await importActual<typeof import('./RouteCalculator')>()
@@ -212,4 +212,24 @@ describe('tripRouteGeometry', () => {
vi.useRealTimers() vi.useRealTimers()
} }
}) })
it('FE-MAP-TRG-012: the overview draws no drive between two stays on the day a flight connects them (#2476)', () => {
// Day 2 moves from a Munich hotel to a Hamburg one. The overview reads the same
// day plan as the map, so the flight saved without airports keeps the road off it.
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
const accommodations = [
{ id: 1, trip_id: 1, place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 },
{ id: 2, trip_id: 1, place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 3 },
] as unknown as Accommodation[]
const flight = {
id: 80, trip_id: 1, type: 'flight', title: 'LH 2078', day_id: 2, end_day_id: 2,
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20', endpoints: [],
} as unknown as Reservation
const moving = { days, assignments: {}, accommodations, optimizeFromAccommodation: true }
expect(planTripRoute({ ...moving, reservations: [flight] }, 'driving')).toEqual([])
// Without the booking the day is a drive from one hotel to the other, as before (#1297).
const byRoad = planTripRoute({ ...moving, reservations: [] }, 'driving')
expect(byRoad.map(d => d.day.id)).toEqual([2])
})
}) })
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server' import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute' import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route' const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
@@ -365,3 +365,62 @@ describe('valhallaRun over a whole day', () => {
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull() await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
}) })
}) })
describe('valhallaAlternates', () => {
/** The same leg read three ways, the second of them crossing by ferry. */
const withAlternates = (alternates: unknown[]) => ({
...answer({ time: 11_460 }),
alternates,
})
it('VALHALLA-ALT-001: reads the preferred way, then every alternate, each with its own flags', () => {
const ways = alternatesFrom(withAlternates([
answer({ time: 12_000, has_ferry: true }),
answer({ time: 13_000, has_toll: true }),
]))
expect(ways?.map(w => w.duration)).toEqual([11_460, 12_000, 13_000])
expect(ways?.map(w => w.hasFerry)).toEqual([false, true, false])
expect(ways?.map(w => w.hasToll)).toEqual([false, false, true])
expect(ways?.[1].coordinates).toEqual(SHAPE_POINTS)
})
it('VALHALLA-ALT-002: an alternate that cannot be read is dropped, a preferred way that cannot is no answer', () => {
// One bad entry is no reason to lose the leg's other ways, but the others are offered
// against the preferred one and mean nothing without it.
expect(alternatesFrom(withAlternates([{ trip: { legs: [] } }, answer({ time: 13_000 })]))?.map(w => w.duration))
.toEqual([11_460, 13_000])
expect(alternatesFrom(answer())).toHaveLength(1)
expect(alternatesFrom({ alternates: [answer()] })).toBeNull()
expect(alternatesFrom({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
})
it('VALHALLA-ALT-003: asks for the leg between exactly its two ends, weighed the way the trip is, with the number of alternates', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json(withAlternates([answer({ time: 12_000 })]))
}))
const ways = await valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll', 'ferry'], 2)
await valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 0)
expect(ways).toHaveLength(2)
expect(bodies[0]).toMatchObject({ alternates: 2, costing: 'auto', costing_options: { auto: { use_tolls: 0, use_ferry: 0 } } })
expect(bodies[0].locations).toHaveLength(2)
// No alternates asked for, none named: the request stays the plain one.
expect(bodies[1]).not.toHaveProperty('alternates')
})
it('VALHALLA-ALT-004: a refusal is asked once more, then is no answer; an instance with its own OSRM asks nothing', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return new HttpResponse(null, { status: 429 }) }))
await expect(valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 2)).resolves.toBeNull()
expect(calls).toBe(2)
setSettings({ routing_base_url: 'https://osrm.example.org' })
await expect(valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 2)).resolves.toBeNull()
expect(calls).toBe(2)
})
})
+90 -15
View File
@@ -204,14 +204,7 @@ export async function valhallaRun(
// rate limit on the last piece of a split day would otherwise throw away the pieces // rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a // that already answered and ask for all of them again — three times over, against a
// host that allows one request a second. // host that allows one request a second.
let run = await requestRun(base, chunk, profile, avoid, signal) const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal), signal)
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
// Not when the host just proved it is not a Valhalla at all: that answer will not
// change on a second ask, and asking anyway is the request the memo exists to save.
if (notValhalla.has(base)) break
await pause(CHUNK_PAUSE_MS, signal)
run = await requestRun(base, chunk, profile, avoid, signal)
}
// A day is one answer or none. Half a day of legs would be worse than no answer: // A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so // the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap. // it would print a complete, plausible, wrong timetable rather than a gap.
@@ -260,14 +253,39 @@ function pause(ms: number, signal?: AbortSignal): Promise<void> {
}) })
} }
/** One request, at most ten locations. */ /**
async function requestRun( * Waits out the host's one request a second before the next question is put.
base: string, *
* For a caller that asks this engine several things in a row on its own, such as the
* offers for one leg: the same pause the pieces of a split day keep, so a picker cannot
* fire at a pace the day routing was careful never to.
*/
export function valhallaSpacing(signal?: AbortSignal): Promise<void> {
return pause(CHUNK_PAUSE_MS, signal)
}
/**
* One question, and one more try after a pause when it earned no answer.
*
* Not when the host just proved it is not a Valhalla at all: that answer will not change
* on a second ask, and asking anyway is the request the memo exists to save.
*/
async function askWithRetry<T>(base: string, ask: () => Promise<T | null>, signal?: AbortSignal): Promise<T | null> {
let answer = await ask()
for (let attempt = 0; !answer && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
if (notValhalla.has(base)) break
await pause(CHUNK_PAUSE_MS, signal)
answer = await ask()
}
return answer
}
/** The request body for a chain of waypoints, weighted away from `avoid`. */
function routeBody(
waypoints: Waypoint[], waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling', profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[], avoid: readonly AvoidClass[],
signal?: AbortSignal, ): Record<string, unknown> {
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const costing = COSTING[profile] const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to // All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to // leave out tolls and ferries together is one question rather than two routes to
@@ -275,13 +293,16 @@ async function requestRun(
// away together answered 656.9 km against 339.2 km for the plain route. // away together answered 656.9 km against 339.2 km for the plain route.
const options: Record<string, number> = {} const options: Record<string, number> = {}
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0 for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
const body = { return {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })), locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
costing, costing,
costing_options: { [costing]: options }, costing_options: { [costing]: options },
directions_options: { units: 'kilometers' }, directions_options: { units: 'kilometers' },
} }
}
/** The parsed answer to one POST, or null for every failure. */
async function postRoute(base: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
try { try {
const response = await fetch(`${base}/route`, { const response = await fetch(`${base}/route`, {
method: 'POST', method: 'POST',
@@ -299,12 +320,66 @@ async function requestRun(
if (response.status === 404 || response.status === 405) notValhalla.add(base) if (response.status === 404 || response.status === 405) notValhalla.add(base)
return null return null
} }
return runFrom(await response.json()) return await response.json()
} catch { } catch {
return null return null
} }
} }
/** One request, at most ten locations. */
async function requestRun(
base: string,
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid), signal)
return data === null ? null : runFrom(data)
}
/**
* The ways of driving one leg this engine would offer, its preferred one first, or null.
*
* The offers for a trip that avoids something have to come from the engine that routes
* that trip's days, or the list and the rail describe two different drives: OSRM's
* quickest road beside a day Valhalla had routed was offered as the faster way, and
* taking it changed nothing. Valhalla answers `alternates` between exactly two locations,
* which is all one leg is, and weighs every one of them the way it weighed the day.
*/
export async function valhallaAlternates(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
count: number,
signal?: AbortSignal,
): Promise<ValhallaLeg[] | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base)) return null
const body = { ...routeBody([from, to], profile, avoid), ...(count > 0 ? { alternates: count } : {}) }
return askWithRetry(base, async () => {
const data = await postRoute(base, body, signal)
return data === null ? null : alternatesFrom(data)
}, signal)
}
/**
* Every way in an answer that asked for alternates: `trip` first, then each
* `alternates[].trip`, read the way `runFrom` reads a single leg.
*
* Null when the preferred one cannot be read, because the others are offered against it.
* An alternate that cannot be read is dropped on its own: one bad entry is no reason to
* lose the leg's other ways.
*/
export function alternatesFrom(data: unknown): ValhallaLeg[] | null {
const primary = runFrom(data)
if (!primary) return null
const extra = (data as { alternates?: unknown })?.alternates
const others = Array.isArray(extra) ? extra.map(alt => runFrom(alt)?.total ?? null) : []
return [primary.total, ...others.filter((leg): leg is ValhallaLeg => leg !== null)]
}
/** /**
* Several legs read as one, the way a caller that only wants the whole drive sees it. * Several legs read as one, the way a caller that only wants the whole drive sees it.
* *
+34 -1
View File
@@ -31,6 +31,12 @@ function getIframe(): HTMLIFrameElement | null {
return document.querySelector('#pdf-preview-overlay iframe') return document.querySelector('#pdf-preview-overlay iframe')
} }
/** The cover's "Planned" figure. The stat is read off its label, since days and places print the same markup. */
function plannedStat(html: string): number | null {
const match = /<div class="cover-stat-num">(\d+)<\/div>\s*<div class="cover-stat-lbl">pdf\.planned<\/div>/.exec(html)
return match ? Number(match[1]) : null
}
// ── Setup ───────────────────────────────────────────────────────────────────── // ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => { beforeEach(() => {
@@ -257,7 +263,7 @@ describe('downloadTripPDF', () => {
// The desktop toolbar hands the export the store's assignments, hidden stop and // 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 // 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. // booking as the accommodation block, and the print lists what the plan lists.
const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null } as any const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null, price: '120' } as any
const hotelStop = { id: 201, day_id: 10, place_id: 101, order_index: 0, accommodation_id: 30, place: hotelPlace } const 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' } const flight = { ...transportReservation, title: 'Morning flight', reservation_time: '2025-06-01T08:00:00' }
await downloadTripPDF({ await downloadTripPDF({
@@ -269,6 +275,33 @@ describe('downloadTripPDF', () => {
expect(html).not.toContain('Seewartenstr. 9') expect(html).not.toContain('Seewartenstr. 9')
expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum')) expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum'))
expect(html.indexOf('Morning flight')).toBeGreaterThan(-1) expect(html.indexOf('Morning flight')).toBeGreaterThan(-1)
// The cover and the day header read the same list: the hidden stop is not a planned
// stop and its hotel's price is not part of the day, whichever shell asked.
expect(plannedStat(html)).toBe(1)
const money = html.replace(/[\u00a0\u202f]/g, ' ')
expect(money).toContain('15,00 €')
expect(money).not.toContain('135,00 €')
})
it('FE-COMP-TRIPPDF-013g: a service stop the plan keeps to the road trip view is not printed either, and comes back with the switch', async () => {
// "Show in Days too" off keeps petrol stations and rest areas out of the day lists.
// The print follows the same switch, so the desktop, which hands over the store's
// assignments, and the phone print the same day.
const pump = { id: 102, name: 'Aral Autohof', address: 'Autobahnkreuz 1', stop_type: 'fuel', place_time: null } as any
const pumpStop = { id: 202, day_id: 10, place_id: 102, order_index: 1, place: pump }
const assignments = { '10': [assignmentForDay, pumpStop] } as any
await downloadTripPDF({ ...richArgs, assignments, showServiceStops: false })
const hidden = getIframe()!.srcdoc
expect(hidden).not.toContain('Aral Autohof')
expect(hidden).toContain('Colosseum')
expect(plannedStat(hidden)).toBe(1)
// On, and by default, the stop prints where the plan lists it.
getOverlay()?.remove()
await downloadTripPDF({ ...richArgs, assignments })
const shown = getIframe()!.srcdoc
expect(shown).toContain('Aral Autohof')
expect(plannedStat(shown)).toBe(2)
}) })
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => { it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
+31 -10
View File
@@ -16,6 +16,7 @@ import { renderTripMapImage } from './tripMapImage'
import { formatDistance } from '../../utils/units' import { formatDistance } from '../../utils/units'
import { fetchExchangeRates } from '../../hooks/useExchangeRates' import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs' import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
/** /**
* Every day starts a new page by default. On a trip of short days that prints * Every day starts a new page by default. On a trip of short days that prints
@@ -199,11 +200,34 @@ interface downloadTripPDFProps {
timeFormat?: string timeFormat?: string
/** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */ /** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */
distanceUnit?: string distanceUnit?: string
/**
* The road trip setting "Show in Days too" (`roadtrip_service_stops_in_days`), read as a
* prop for the same reason. Off, the day plan keeps petrol stations and rest areas to
* the road trip view, and so does the print.
*/
showServiceStops?: boolean
} }
// `assignments` is normalised here once — every read below (and fetchPlacePhotos) /**
// relies on it being an object. * The stops the day plan lists, out of everything the store holds for the trip.
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit }: downloadTripPDFProps) { *
* The stop a booked night wrote onto its check-in day is out whatever the switch says:
* the day already shows that booking as its accommodation block, and the row would be
* the same hotel a second time. The service stops go with the setting. One predicate
* for the whole document, so the day lists, the route map, the cover's planned count
* and every cost total agree with the plan and with each other, whichever shell asked.
*/
function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean): AssignmentsMap {
return Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [
dayId,
(list || []).filter(a => a.accommodation_id == null && (showServiceStops || !isServiceStopType(a.place?.stop_type))),
]))
}
// `assignments` is normalised here once, to the plan's own list; every read below
// (and fetchPlacePhotos) relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments: stored = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true }: downloadTripPDFProps) {
const assignments = planAssignments(stored, showServiceStops)
const breaksPerDay = pageBreakPerDay() const breaksPerDay = pageBreakPerDay()
const loc = _locale || undefined const loc = _locale || undefined
const tr = _t || (k => k) const tr = _t || (k => k)
@@ -335,14 +359,11 @@ 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) => {
// Without the stop a booked night wrote onto its check-in day: the day plan hides // What the plan lists (planAssignments): the stop a booked night wrote sat at the
// it (the day already carries the booking as its accommodation block) and the // head of its day, so the hotel printed above a morning flight (#2434).
// 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)] || []) const assigned = (assignments[String(day.id)] || [])
.filter((a: any) => a.accommodation_id == null) .slice()
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0)) .sort((a, b) => (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))
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates) const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
@@ -33,6 +33,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
const trip = useTripStore(s => s.trip) const trip = useTripStore(s => s.trip)
const displayCurrency = useSettingsStore(s => s.settings.default_currency) const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
// An amount is printed in its own currency, unconverted. One saved without a
// currency is in the trip's own (#2525), which is how Costs reads it; labelling
// it with the display currency turned a 120 EUR deposit into $120.00.
const ownCurrency = (currency: string | null | undefined) => currency || trip?.currency || base
const linked = reservationId const linked = reservationId
? budgetItems.find(i => i.reservation_id === reservationId) ? budgetItems.find(i => i.reservation_id === reservationId)
: placeId : placeId
@@ -54,7 +58,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div> <div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div> <div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
</div> </div>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span> <span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
</div> </div>
</div> </div>
) )
@@ -72,7 +76,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div> <div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div> <div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
</div> </div>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span> <span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, ownCurrency(linked.currency), locale)}</span>
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button> <button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button> <button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
</div> </div>
@@ -0,0 +1,103 @@
import { useId, useState } from 'react'
import { CalendarPlus, Plus } from 'lucide-react'
import { dayDate } from '../../utils/dayLabel'
import type { DayAddControls } from '../../utils/dayAdd'
interface DayAddFooterProps {
/** Without it, or on a trip without dates, the footer keeps its single "Add day" button. */
dayAdd?: DayAddControls
onAddDay: () => void
onClose: () => void
t: (key: string, params?: Record<string, string | number>) => string
locale: string
}
/** One button size for the whole foot of the reorder dialog, the delete question's included. */
export const FOOTER_BTN =
'inline-flex min-h-9 flex-shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-body font-medium transition-colors disabled:cursor-default disabled:opacity-40'
/** Cancel, Close and the quiet add: one outline, one text colour, so none of them looks switched off beside another. */
export const SECONDARY_BTN = `${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover hover:text-content disabled:hover:bg-transparent`
const CLOSE_BTN = `${SECONDARY_BTN} ml-auto`
/** The add that matters most: drawn in the accent, not filled with it, so it leads without weighing on the row. */
const MAIN_BTN = `${FOOTER_BTN} border border-accent-on bg-transparent font-semibold text-accent-on hover:bg-accent-subtle disabled:hover:bg-transparent`
/**
* The foot of the reorder dialog: close it, or add a day.
*
* On a trip with dates there are two ways to add one, in one row with Close. The
* next calendar day, named on its button, extends the trip by one; a day without
* a date goes to the end and leaves the trip dates alone. The line above them
* says what the button in view does, and switches while the pointer or the focus
* rests on the other one. When no day can be added it says why instead.
*/
export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFooterProps) {
const [aboutUndated, setAboutUndated] = useState(false)
const hintId = useId()
const busy = dayAdd?.busy ?? false
const blocked = dayAdd?.blocked ?? null
const addOff = busy || !!blocked
const closeButton = (
<button type="button" onClick={onClose} className={CLOSE_BTN}>
{t('common.close')}
</button>
)
if (!dayAdd?.nextDate) {
return (
<div className="flex flex-col gap-2.5">
{blocked && <p className="m-0 text-caption text-content-muted">{blocked}</p>}
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={onAddDay} disabled={addOff} className={MAIN_BTN}>
<Plus size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.addDay')}
</button>
{closeButton}
</div>
</div>
)
}
const date = dayDate(dayAdd.nextDate, locale) ?? dayAdd.nextDate
const hint = blocked
?? (aboutUndated ? t('dayplan.addUndatedDayHint') : dayAdd.datedBlocked ?? t('dayplan.addDatedDayHint', { date }))
const pointAtUndated = {
onMouseEnter: () => setAboutUndated(true),
onMouseLeave: () => setAboutUndated(false),
onFocus: () => setAboutUndated(true),
onBlur: () => setAboutUndated(false),
}
return (
<div className="flex flex-col gap-2.5">
<p id={hintId} aria-live="polite" className="m-0 text-caption text-content-muted">
{hint}
</p>
{/* Should a language run long, the row wraps between buttons, never inside one. */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={dayAdd.onAddDated}
disabled={addOff || !!dayAdd.datedBlocked}
aria-describedby={hintId}
className={MAIN_BTN}
>
<CalendarPlus size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.addDatedDay', { date })}
</button>
<button
type="button"
onClick={onAddDay}
disabled={addOff}
aria-describedby={hintId}
className={SECONDARY_BTN}
{...pointAtUndated}
>
<Plus size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.addUndatedDay')}
</button>
{closeButton}
</div>
</div>
)
}
@@ -1,4 +1,4 @@
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-080 // FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-085
import React from 'react'; import React from 'react';
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render'; import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
@@ -12,6 +12,7 @@ import { usePluginStore } from '../../store/pluginStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'; import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel'; import DayDetailPanel from './DayDetailPanel';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' }); const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
@@ -519,6 +520,15 @@ describe('DayDetailPanel', () => {
expect(await screen.findByText('102 Quai de Jemmapes')).toBeInTheDocument(); expect(await screen.findByText('102 Quai de Jemmapes')).toBeInTheDocument();
}); });
it('FE-PLANNER-DAYDETAIL-085: the hotel picker leaves out imported tracks', async () => {
const hotel = buildPlace({ id: 10, name: 'Hotel du Nord' });
const track = buildPlace({ id: 12, name: 'Canal Saint-Martin walk', route_geometry: '[[48.87,2.36],[48.88,2.37]]' });
render(<DayDetailPanel {...defaultProps} places={[hotel, track]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await screen.findByText('Hotel du Nord');
expect(screen.queryByText('Canal Saint-Martin walk')).not.toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-030: selecting a place in hotel picker enables save button', async () => { it('FE-PLANNER-DAYDETAIL-030: selecting a place in hotel picker enables save button', async () => {
const place = buildPlace({ id: 10, name: 'Maison Blanche' }); const place = buildPlace({ id: 10, name: 'Maison Blanche' });
server.use( server.use(
@@ -1896,3 +1906,80 @@ describe('DayDetailPanel time format', () => {
expect(screen.getByText('11:00')).toBeInTheDocument(); expect(screen.getByText('11:00')).toBeInTheDocument();
}); });
}); });
// ── Blur booking codes: the stay's own code and the hotel editor (#2457) ────
describe('DayDetailPanel blur booking codes (#2457)', () => {
beforeEach(() => {
server.use(http.get('/api/view-contributions/:view/:tripId', () => HttpResponse.json({ contributions: [] })));
});
const blurOn = (on: boolean) => seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: on },
});
const stayWithCode = (confirmation: string) => server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Code Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation,
}],
})
),
);
const openHotelEditor = async () => {
await screen.findByText('Code Hotel');
// header collapse (0), header close (1), pencil (2), remove (3), as in DAYDETAIL-032
await userEvent.click(screen.getAllByRole('button')[2]);
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')?.textContent).toMatch(/Edit accommodation/i);
});
};
it('FE-PLANNER-DAYDETAIL-081: the confirmation code of the stay itself is blurred in the details grid', async () => {
blurOn(true);
stayWithCode('HOTEL-SECRET');
render(<DayDetailPanel {...defaultProps} />);
const code = await screen.findByText('HOTEL-SECRET');
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-DAYDETAIL-082: the confirmation field of the hotel editor is blurred until focused', async () => {
blurOn(true);
stayWithCode('HOTEL-SECRET');
render(<DayDetailPanel {...defaultProps} />);
await openHotelEditor();
const field = screen.getByDisplayValue('HOTEL-SECRET') as HTMLInputElement;
expect(isBlurred(field)).toBe(true);
act(() => field.focus());
expect(isBlurred(field)).toBe(false);
act(() => field.blur());
expect(isBlurred(field)).toBe(true);
});
it('FE-PLANNER-DAYDETAIL-083: with the setting off the stay code and the editor field stay plain', async () => {
blurOn(false);
stayWithCode('HOTEL-PLAIN');
render(<DayDetailPanel {...defaultProps} />);
expect(isBlurred(await screen.findByText('HOTEL-PLAIN'))).toBe(false);
await openHotelEditor();
expect(isBlurred(screen.getByDisplayValue('HOTEL-PLAIN'))).toBe(false);
});
it('FE-PLANNER-DAYDETAIL-084: a blurred code in the hotel editor still saves unchanged', async () => {
blurOn(true);
let body: Record<string, unknown> | null = null;
stayWithCode('HOTEL-SECRET');
server.use(
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ accommodation: { id: 1, place_id: 5, place_name: 'Code Hotel', start_day_id: 1, end_day_id: 3, confirmation: 'HOTEL-SECRET' } });
}),
);
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 5, name: 'Code Hotel' })]} />);
await openHotelEditor();
await userEvent.click(screen.getByText(/Save/i));
await waitFor(() => expect(body).not.toBeNull());
expect(body!.confirmation).toBe('HOTEL-SECRET');
});
});
@@ -13,6 +13,7 @@ import { useTripStore } from '../../store/tripStore'
import { applyStayStops } from '../../store/stayStops' import { applyStayStops } from '../../store/stayStops'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { BlurredCode, BookingCodeInput } from '../shared/BookingCode'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { getLocaleForLanguage, useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
@@ -20,6 +21,7 @@ import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../ty
import { isDayInAccommodationRange } from '../../utils/dayOrder' import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { formatClockTime, splitReservationDateTime } from '../../utils/formatters' import { formatClockTime, splitReservationDateTime } from '../../utils/formatters'
import { useDayDetail } from './useDayDetail' import { useDayDetail } from './useDayDetail'
import { stayPlaces } from '../../utils/stayPlaces'
const WEATHER_ICON_MAP = { const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -453,7 +455,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
)} )}
{acc.confirmation && ( {acc.confirmation && (
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}> <div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{acc.confirmation}</div> <div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}><BlurredCode>{acc.confirmation}</BlurredCode></div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}> <div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<Hash size={8} /> {t('day.confirmation')} <Hash size={8} /> {t('day.confirmation')}
</div> </div>
@@ -632,7 +634,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
</div> </div>
<div style={{ flex: 2, minWidth: 120 }}> <div style={{ flex: 2, minWidth: 120 }}>
<label style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.confirmation')}</label> <label style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.confirmation')}</label>
<input type="text" value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))} <BookingCodeInput value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} /> placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} />
</div> </div>
</div> </div>
@@ -659,7 +661,8 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
{/* Place List */} {/* Place List */}
<div style={{ maxHeight: 250, overflowY: 'auto' }}> <div style={{ maxHeight: 250, overflowY: 'auto' }}>
{(() => { {(() => {
const filtered = hotelCategoryFilter ? places.filter(p => p.category_id === hotelCategoryFilter) : places const offered = stayPlaces<Place>(places, hotelForm.place_id)
const filtered = hotelCategoryFilter ? offered.filter(p => p.category_id === hotelCategoryFilter) : offered
return filtered.length === 0 ? ( return filtered.length === 0 ? (
<div style={{ padding: 20, textAlign: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div> <div style={{ padding: 20, textAlign: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
) : filtered.map(p => ( ) : filtered.map(p => (
@@ -101,14 +101,15 @@ vi.mock('../shared/Toast', () => ({
// ── Permissions mock ──────────────────────────────────────────────────────── // ── Permissions mock ────────────────────────────────────────────────────────
// Flipped per test to render the read-only variants of the day rows. // Flipped per test to render the read-only variants of the day rows; `denied`
const mockPermissions = vi.hoisted(() => ({ canEdit: true })) // takes single actions away from an otherwise editing member.
const mockPermissions = vi.hoisted(() => ({ canEdit: true, denied: new Set<string>() }))
vi.mock('../../store/permissionsStore', async (importOriginal) => { vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any const actual = await importOriginal() as any
return { return {
...actual, ...actual,
useCanDo: () => () => mockPermissions.canEdit, useCanDo: () => (action: string) => mockPermissions.canEdit && !mockPermissions.denied.has(action),
} }
}) })
@@ -182,6 +183,7 @@ beforeEach(() => {
resetAllStores() resetAllStores()
vi.clearAllMocks() vi.clearAllMocks()
mockPermissions.canEdit = true mockPermissions.canEdit = true
mockPermissions.denied.clear()
// clearAllMocks keeps implementations, so tests that swap the router out would // clearAllMocks keeps implementations, so tests that swap the router out would
// otherwise leak into the ones after them. // otherwise leak into the ones after them.
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({ vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
@@ -938,6 +940,18 @@ describe('DayPlanSidebar', () => {
expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id) expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id)
}) })
it('FE-PLANNER-DAYPLAN-214: without place_edit the menu still removes from the day but neither edits nor deletes the place (#2446)', () => {
mockPermissions.denied.add('place_edit')
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
fireEvent.contextMenu(screen.getByText('Louvre Museum'))
expect(screen.getByText(/Remove from day/i)).toBeInTheDocument()
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
})
// ── Arrow reorder buttons ──────────────────────────────────────────────── // ── Arrow reorder buttons ────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => { it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => {
@@ -2508,6 +2522,86 @@ describe('DayPlanSidebar', () => {
expect(stored!.day_plan_position).toBe(positions[1].day_plan_position) expect(stored!.day_plan_position).toBe(positions[1].day_plan_position)
}) })
// The crossing from the report behind #2461: Amsterdam and Newcastle on the day, the
// ferry from IJmuiden to the Port of Tyne in the evening. The slot written here is the
// one every other reader takes from then on, the road trip included.
const crossing = (newcastleAt: string | null, withAmsterdam = true) => {
const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' })
const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 })
const newcastle = buildPlace({ id: 2, name: 'Newcastle', lat: 54.9783, lng: -1.6178, place_time: newcastleAt })
const stops = [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: amsterdam }),
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: newcastle }),
].filter(a => withAmsterdam || a.id !== 11)
const ferry = buildReservation({
id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10,
reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null },
],
})
seedStore(useTripStore, { reservations: [ferry] })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [amsterdam, newcastle], reservations: [ferry], assignments: { '10': stops },
})} />)
}
it('FE-PLANNER-DAYPLAN-218: a ferry between two untimed stops on its two shores is given the slot between them (#2461)', async () => {
const { reservationsApi } = await import('../../api/client')
crossing(null)
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
// By the clock alone it closed the day at 2.5, and the drive went overland first.
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
expect(useTripStore.getState().reservations.find(r => r.id === 69)!.day_plan_position).toBe(0.5)
})
it('FE-PLANNER-DAYPLAN-219: a stop timed before the ferry keeps it behind that stop (#2461)', async () => {
const { reservationsApi } = await import('../../api/client')
crossing('10:00')
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 1.5 }])
})
it('FE-PLANNER-DAYPLAN-220: a ferry landing next to the only stop of the day opens it (#2461)', async () => {
const { reservationsApi } = await import('../../api/client')
crossing(null, false)
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
// Ahead of Newcastle, which is stored at order_index 1 here.
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
})
it('FE-PLANNER-DAYPLAN-221: the slot is worked out over the hotel the list hides, so it does not land behind it (#2461)', async () => {
// The hotel a booking put on the day sits across the water and is no row of the list.
// Worked out over Amsterdam alone, the clock closed the day at 1.5: behind the hotel
// for the road trip, which then drove to Newcastle overland before the crossing.
const { reservationsApi } = await import('../../api/client')
const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' })
const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 })
const hotel = buildPlace({ id: 3, name: 'Hotel Newcastle', lat: 54.975, lng: -1.61 })
const ferry = buildReservation({
id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10,
reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null },
],
})
seedStore(useTripStore, { reservations: [ferry] })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [amsterdam, hotel], reservations: [ferry],
accommodations: [{ id: 7, place_id: 3, start_day_id: 10, end_day_id: 11, check_in: null } as never],
assignments: {
'10': [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: amsterdam }),
buildAssignment({ id: 13, day_id: 10, order_index: 1, place: hotel, accommodation_id: 7 } as never),
],
},
})} />)
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
})
it('FE-PLANNER-DAYPLAN-204: a rejected slot write puts the bookings back where the server has them', async () => { it('FE-PLANNER-DAYPLAN-204: a rejected slot write puts the bookings back where the server has them', async () => {
const { reservationsApi } = await import('../../api/client') const { reservationsApi } = await import('../../api/client')
vi.mocked(reservationsApi.updatePositions).mockRejectedValueOnce(new Error('offline')) vi.mocked(reservationsApi.updatePositions).mockRejectedValueOnce(new Error('offline'))
@@ -2797,7 +2891,8 @@ describe('DayPlanSidebar', () => {
expect(onRouteRefresh).toHaveBeenCalled() expect(onRouteRefresh).toHaveBeenCalled()
// The store carries the new slot per day so the merged list stays stable. // The store carries the new slot per day so the merged list stays stable.
expect(useTripStore.getState().reservations[0].day_positions).toEqual({ 10: expect.any(Number) }) expect(useTripStore.getState().reservations[0].day_positions).toEqual({ 10: expect.any(Number) })
// Undoing restores the original assignment order. // Undoing restores the original assignment order; the step names its day, so deleting the day drops it.
expect(pushUndo.mock.calls[0][2]).toEqual([10])
const undo = pushUndo.mock.calls[0][1] as () => Promise<void> const undo = pushUndo.mock.calls[0][1] as () => Promise<void>
await undo() await undo()
expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12]) expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12])
@@ -3330,6 +3425,65 @@ describe('DayPlanSidebar', () => {
expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 }) expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 })
}) })
// #2455. The list draws a timed stop by its start, the road trip drives the stored
// order. A stop with a start moved onto another day has to be stored where the list
// will draw it, or the road trip visits it somewhere else and, behind a later start,
// reaches it late.
describe('a timed stop moved onto another day is stored where the list shows it (#2455)', () => {
const setup = (extra: Record<string, unknown> = {}) => {
const moveAssignment = vi.fn(async () => undefined)
stubTripActions({ moveAssignment })
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
]
const moved = buildPlace({ id: 1, name: 'Travemuende Strand', place_time: '10:00' })
const early = buildPlace({ id: 2, name: 'Luebeck Dom', place_time: '09:00' })
const middle = buildPlace({ id: 3, name: 'Wismar Hafen', place_time: '11:00' })
const late = buildPlace({ id: 4, name: 'Rostock Markt', place_time: '14:00' })
const assignments = {
'10': [buildAssignment({ id: 21, day_id: 10, order_index: 0, place: moved })],
'11': [
buildAssignment({ id: 31, day_id: 11, order_index: 0, place: early }),
buildAssignment({ id: 32, day_id: 11, order_index: 1, place: middle }),
buildAssignment({ id: 33, day_id: 11, order_index: 2, place: late }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, places: [moved, early, middle, late], assignments, ...extra })} />)
return moveAssignment
}
it('FE-PLANNER-DAYPLAN-215: dropped on the day body, it lands between the stops its start falls between', () => {
const moveAssignment = setup()
fireEvent.dragStart(dragRow(screen.getByText('Travemuende Strand')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(document.querySelectorAll('[style*="padding-top: 6px"]')[1], { dataTransfer: { getData: vi.fn(() => '') } })
// 10:00 sits between 09:00 (index 0) and 11:00, so index 1, not the end of the day.
expect(moveAssignment).toHaveBeenCalledWith(1, 21, 10, 11, 1)
})
it('FE-PLANNER-DAYPLAN-216: dropped on a later stop, it still lands where its start puts it', () => {
const moveAssignment = setup()
fireEvent.dragStart(dragRow(screen.getByText('Travemuende Strand')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dragRow(screen.getByText('Rostock Markt')), { dataTransfer: { getData: vi.fn(() => '') } })
expect(moveAssignment).toHaveBeenCalledWith(1, 21, 10, 11, 1)
})
it('FE-PLANNER-DAYPLAN-217: with the planner wired in, the move goes through it so the day keeps its vias', async () => {
// Landing in the middle of a day shifts the legs behind it, which only the planner
// can correct: it holds the vias. The list still decides where the stop goes.
const onMoveToDay = vi.fn(async () => undefined)
const pushUndo = vi.fn()
const moveAssignment = setup({ onMoveToDay, pushUndo })
fireEvent.dragStart(dragRow(screen.getByText('Travemuende Strand')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dayHeader('Day 2'), { dataTransfer: { getData: vi.fn(() => '') } })
expect(onMoveToDay).toHaveBeenCalledWith(21, 10, 11, 1)
expect(moveAssignment).not.toHaveBeenCalled()
// Still offered back once the planner has made the move, tagged with both days it touches.
await waitFor(() => expect(pushUndo).toHaveBeenCalledTimes(1))
expect(pushUndo.mock.calls[0][2]).toEqual([11, 10])
})
})
it('FE-PLANNER-DAYPLAN-149: the note context menu edits and asks before deleting', async () => { it('FE-PLANNER-DAYPLAN-149: the note context menu edits and asks before deleting', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const note = buildDayNote({ id: 70, day_id: 10, text: 'A note' }) const note = buildDayNote({ id: 70, day_id: 10, text: 'A note' })
@@ -3782,6 +3936,76 @@ describe('DayPlanSidebar', () => {
openSpy.mockRestore() openSpy.mockRestore()
}) })
describe('a moving day with a flight between the two stays (#2476)', () => {
// Day 2 checks out of a Munich hotel and into a Hamburg one, and nothing else is
// planned on it. The booked night's own stop never reaches the list.
const movingDays = [
buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' }),
buildDay({ id: 11, date: '2026-11-04', title: 'Day 2' }),
buildDay({ id: 12, date: '2026-11-05', title: 'Day 3' }),
]
const stays: Accommodation[] = [
{ id: 1, trip_id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.137, place_lng: 11.575, place_name: 'Hotel A' },
{ id: 2, trip_id: 1, start_day_id: 11, end_day_id: 12, place_lat: 53.551, place_lng: 9.993, place_name: 'Hotel B' },
]
const airport = (role: 'from' | 'to', name: string, lat: number, lng: number) =>
({ role, sequence: role === 'from' ? 0 : 1, name, code: null, lat, lng, timezone: null, local_date: null, local_time: null })
const flight = (located: boolean) => buildReservation({
id: 77, type: 'flight', title: 'LH 2078', day_id: 11, end_day_id: 11,
reservation_time: '2026-11-04T15:15:00', reservation_end_time: '2026-11-04T17:20:00',
endpoints: located ? [airport('from', 'MUC', 48.353, 11.786), airport('to', 'HAM', 53.63, 9.988)] : [],
})
it('FE-PLANNER-DAYPLAN-222: offers no Google Maps or CoMaps route from one hotel to the other', () => {
for (const located of [true, false]) {
const { unmount } = render(<DayPlanSidebar {...makeDefaultProps({
days: movingDays, accommodations: stays, reservations: [flight(located)], selectedDayId: 11,
})} />)
// With its airports the route to and from them can still be shown; without
// them there is nothing left to draw, so the tools go as a whole instead of
// standing there dead. The hand-offs, which could only ever describe a drive
// from Munich to Hamburg, are gone either way.
if (located) expect(screen.getByRole('button', { name: 'Route' })).toBeInTheDocument()
else expect(screen.queryByRole('button', { name: 'Route' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open in CoMaps' })).not.toBeInTheDocument()
unmount()
}
})
it('FE-PLANNER-DAYPLAN-223: the hotel legs run to the departure airport and from the arrival airport', async () => {
render(<DayPlanSidebar {...makeDefaultProps({
days: movingDays, accommodations: stays, reservations: [flight(true)], selectedDayId: 11, routeShown: true,
})} />)
await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalledTimes(2))
const pairs = vi.mocked(calculateRouteWithLegs).mock.calls.map(c => c[0])
expect(pairs).toContainEqual([{ lat: 48.137, lng: 11.575 }, { lat: 48.353, lng: 11.786 }])
expect(pairs).toContainEqual([{ lat: 53.63, lng: 9.988 }, { lat: 53.551, lng: 9.993 }])
expect(pairs).not.toContainEqual([{ lat: 48.137, lng: 11.575 }, { lat: 53.551, lng: 9.993 }])
})
it('FE-PLANNER-DAYPLAN-224: a day with a single located stop still hands it over as a pin', async () => {
// Two stops, one of them without coordinates, and no stay: the route tools show,
// and the hand-offs open the one stop the way they always did.
const user = userEvent.setup()
const { generateGoogleMapsUrl } = await import('../Map/RouteCalculator')
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const day = buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' })
const assignments = {
'10': [
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Elbphilharmonie', lat: 53.541, lng: 9.984 }) }),
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'Somewhere nice', lat: null, lng: null }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10 })} />)
expect(screen.getByRole('button', { name: 'Open in CoMaps' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Open in Google Maps' }))
expect(vi.mocked(generateGoogleMapsUrl)).toHaveBeenCalledWith([{ lat: 53.541, lng: 9.984, name: 'Elbphilharmonie' }])
openSpy.mockRestore()
})
})
it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => { it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const { daysApi } = await import('../../api/client') const { daysApi } = await import('../../api/client')
@@ -4154,6 +4378,7 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, pushUndo, onReorder })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, pushUndo, onReorder })} />)
await user.click(screen.getByRole('button', { name: 'Optimize' })) await user.click(screen.getByRole('button', { name: 'Optimize' }))
await waitFor(() => expect(onReorder).toHaveBeenCalled()) await waitFor(() => expect(onReorder).toHaveBeenCalled())
expect(pushUndo.mock.calls[0][2]).toEqual([10])
const undo = pushUndo.mock.calls[0][1] as () => Promise<void> const undo = pushUndo.mock.calls[0][1] as () => Promise<void>
await undo() await undo()
expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12, 13]) expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12, 13])
@@ -35,10 +35,10 @@ import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels
import { import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints, TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
getAssignmentReservations, getAssignmentReservations, timedSlot, storedRideSlot,
type MergedItem, type MergedItem,
} from '../../utils/dayMerge' } from '../../utils/dayMerge'
import { withinDriveRange } from '../../utils/geo' import { withinDriveRange } from '@trek/shared/roadtrip'
import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationDateTime } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates' import { useExchangeRates } from '../../hooks/useExchangeRates'
@@ -56,6 +56,8 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay' import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation' import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
@@ -76,11 +78,23 @@ interface DayPlanSidebarProps {
onReorder: (dayId: number, orderedIds: number[]) => void onReorder: (dayId: number, orderedIds: number[]) => void
onReorderDays?: (orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void onAddDay?: (position?: number) => void
/** The planner's add controls, for the second "Add with date" button on a trip with dates. */
dayAdd?: DayAddControls
/** Asks to delete a day from the reorder dialog; the planner owns the question. */
onDeleteDay?: (dayId: number) => void
/** The open delete question, which the reorder dialog asks in place of its list. */
deleteDayQuestion?: DayDeleteQuestion | null
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */ /** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
onUpdateDayTitle: (dayId: number, title: string) => void onUpdateDayTitle: (dayId: number, title: string) => void
/** The day route is computed by the planner page itself; kept for the existing call sites. */ /** The day route is computed by the planner page itself; kept for the existing call sites. */
onRouteCalculated: (route: RouteResult | null) => void onRouteCalculated: (route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number, position?: number) => void onAssignToDay: (placeId: number, dayId: number, position?: number) => void
/**
* Moves a stop over from another day, at a row index of that day or at its end, and
* keeps the vias drawn on that day on their legs. Without it the list moves the stop
* in the store and leaves the vias as they are.
*/
onMoveToDay?: (assignmentId: number, fromDayId: number, toDayId: number, position?: number) => Promise<void>
onRemoveAssignment: (dayId: number, assignmentId: number) => void onRemoveAssignment: (dayId: number, assignmentId: number) => void
onEditPlace: (place: Place, assignmentId?: number) => void onEditPlace: (place: Place, assignmentId?: number) => void
onDeletePlace: (placeId: number) => void onDeletePlace: (placeId: number) => void
@@ -102,7 +116,8 @@ interface DayPlanSidebarProps {
/** Open the place form already pointed at this day, to create a new place there. */ /** Open the place form already pointed at this day, to create a new place there. */
onCreatePlaceForDay?: (dayId: number) => void onCreatePlaceForDay?: (dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void /** `dayIds`: the days the step acts on, so deleting one of them drops it. */
pushUndo?: (label: string, undoFn: () => Promise<void> | void, dayIds?: number[]) => void
canUndo?: boolean canUndo?: boolean
lastActionLabel?: string | null lastActionLabel?: string | null
onUndo?: () => void onUndo?: () => void
@@ -140,8 +155,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
trip, days, places, categories, assignments, trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId, selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onReorderDays, onAddDay, onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
visibleConnectionIds = [], visibleConnectionIds = [],
onToggleConnection, onToggleConnection,
@@ -188,6 +203,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const tripActions = useRef(useTripStore.getState()).current const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo() const can = useCanDo()
const canEditDays = can('day_edit', trip) const canEditDays = can('day_edit', trip)
// Editing or deleting the place itself is a place right; taking it off the
// day stays a day right (#2446).
const canEditPlaces = can('place_edit', trip)
// The calendar subscription hands out a link that reads the trip without an // The calendar subscription hands out a link that reads the trip without an
// account, so it sits behind the same permission as the public share link. // account, so it sits behind the same permission as the public share link.
const canManageShare = can('share_manage', trip) const canManageShare = can('share_manage', trip)
@@ -429,7 +447,15 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
.slice().sort((a, b) => a.order_index - b.order_index) .slice().sort((a, b) => a.order_index - b.order_index)
// Compute initial day_plan_position for a transport based on time // Compute initial day_plan_position for a transport based on time
const computeTransportPosition = (r, da) => { const computeTransportPosition = (r, da, dayId) => {
// A ride that lands today goes where it adds the least road between the same clocks,
// by the rule the road trip seats it with. This slot is stored, so seated by the
// clock alone a ferry between two untimed stops stayed at the end of the day, and the
// drive went overland before the crossing (#2461). Worked out over every stored row of
// the day rather than `da`: the drive stops at the hotel and the service stops the
// list hides, and the slot is read against their order indexes too.
const ride = storedRideSlot(r, assignments[String(dayId)] || [], accommodations, dayId)
if (ride !== null) return ride
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0 const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
// Find the last place with time <= transport time // Find the last place with time <= transport time
let afterIdx = -1 let afterIdx = -1
@@ -453,7 +479,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
) )
const positions = sorted.map((r, idx) => ({ const positions = sorted.map((r, idx) => ({
id: r.id, id: r.id,
day_plan_position: computeTransportPosition(r, da) + idx * 0.01, day_plan_position: computeTransportPosition(r, da, dayId) + idx * 0.01,
})) }))
// Mark as initialized immediately to prevent re-entry // Mark as initialized immediately to prevent re-entry
for (const p of positions) initedTransportIds.current.add(p.id) for (const p of positions) initedTransportIds.current.add(p.id)
@@ -507,6 +533,19 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return idx >= 0 ? idx : stored.length return idx >= 0 ? idx : stored.length
} }
// Moving a stop over from another day. One with a start is drawn by it wherever it
// is stored, and the road trip drives the stored order, so it is stored where the
// list will draw it rather than at the drop. One without a start makes the same call
// it always has, dropped where it was dropped or at the end of the day.
const moveToDay = (assignmentId: number, fromDayId: number, toDayId: number, dropAt?: number) => {
const moving = (assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
const slot = timedSlot(assignments[String(toDayId)] || [], accommodations, moving?.place?.place_time, dropAt) ?? dropAt
if (onMoveToDay) return onMoveToDay(assignmentId, fromDayId, toDayId, slot)
return slot === undefined
? tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId)
: tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, slot)
}
// The stop a drop on a note lands ahead of: the next place below the note. // The stop a drop on a note lands ahead of: the next place below the note.
const placeBelowNote = (dayId: number, noteId: number): Assignment | undefined => { const placeBelowNote = (dayId: number, noteId: number): Assignment | undefined => {
const tm = getMergedItems(dayId) const tm = getMergedItems(dayId)
@@ -863,7 +902,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const capturedPrevIds = prevAssignmentIds const capturedPrevIds = prevAssignmentIds
pushUndo?.(t('undo.reorder'), async () => { pushUndo?.(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
}) }, [capturedDayId])
} }
} catch (err: unknown) { } catch (err: unknown) {
rollBackReservations() rollBackReservations()
@@ -1038,7 +1077,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const capturedDayId = dayId const capturedDayId = dayId
pushUndo?.(t('undo.optimize'), async () => { pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
}) }, [capturedDayId])
} }
@@ -1058,11 +1097,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId)) const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId))
const capturedFromDayId = fromDayId const capturedFromDayId = fromDayId
const capturedOrderIndex = srcAssignment?.order_index ?? 0 const capturedOrderIndex = srcAssignment?.order_index ?? 0
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId) moveToDay(Number(assignmentId), fromDayId, dayId)
.then(() => { .then(() => {
pushUndo?.(t('undo.moveDay'), async () => { pushUndo?.(t('undo.moveDay'), async () => {
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex) await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
}) }, [Number(dayId), Number(capturedFromDayId)])
}) })
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) .catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId && fromDayId !== dayId) { } else if (noteId && fromDayId !== dayId) {
@@ -1099,6 +1138,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onReorder, onReorder,
onReorderDays, onReorderDays,
onAddDay, onAddDay,
dayAdd,
onDeleteDay,
deleteDayQuestion,
onAssignToDay, onAssignToDay,
onRemoveAssignment, onRemoveAssignment,
onEditPlace, onEditPlace,
@@ -1148,6 +1190,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
tripActions, tripActions,
can, can,
canEditDays, canEditDays,
canEditPlaces,
canManageShare, canManageShare,
noteUi, noteUi,
setNoteUi, setNoteUi,
@@ -1212,6 +1255,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
getMergedItems, getMergedItems,
storedPositionBefore, storedPositionBefore,
placeBelowNote, placeBelowNote,
moveToDay,
mergedItemsMap, mergedItemsMap,
applyMergedOrder, applyMergedOrder,
handleMergedDrop, handleMergedDrop,
@@ -1299,6 +1343,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onReorder, onReorder,
onReorderDays, onReorderDays,
onAddDay, onAddDay,
dayAdd,
onDeleteDay,
deleteDayQuestion,
onAssignToDay, onAssignToDay,
onRemoveAssignment, onRemoveAssignment,
onEditPlace, onEditPlace,
@@ -1348,6 +1395,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
tripActions, tripActions,
can, can,
canEditDays, canEditDays,
canEditPlaces,
canManageShare, canManageShare,
noteUi, noteUi,
setNoteUi, setNoteUi,
@@ -1412,6 +1460,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
getMergedItems, getMergedItems,
storedPositionBefore, storedPositionBefore,
placeBelowNote, placeBelowNote,
moveToDay,
mergedItemsMap, mergedItemsMap,
applyMergedOrder, applyMergedOrder,
handleMergedDrop, handleMergedDrop,
@@ -1556,6 +1605,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
canManageShare={canManageShare} canManageShare={canManageShare}
onReorderDays={onReorderDays} onReorderDays={onReorderDays}
onAddDay={onAddDay} onAddDay={onAddDay}
dayAdd={dayAdd}
onDeleteDay={onDeleteDay}
deleteDayQuestion={deleteDayQuestion}
/> />
{/* Tagesliste */} {/* Tagesliste */}
@@ -1593,14 +1645,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// useRouteCalculation) even with zero places. Mirror that exact gate here — two // useRouteCalculation) even with zero places. Mirror that exact gate here — two
// distinct bookend hotels you actually slept in / sleep in tonight — so the route // distinct bookend hotels you actually slept in / sleep in tonight — so the route
// tools appear when you click the day (#1297). A same-hotel rest day or a plain // tools appear when you click the day (#1297). A same-hotel rest day or a plain
// arrival/departure day has morning === evening and stays excluded. // arrival/departure day has morning === evening and stays excluded. With a flight
// or train booked on it the map drops that leg (#2476): it keeps only the drives
// to and from the booking's located stations, and a booking without any leaves
// nothing to draw, so the tools stay away instead of sitting there dead.
const transferMorning = routeBookends?.morning const transferMorning = routeBookends?.morning
const transferEvening = routeBookends?.evening const transferEvening = routeBookends?.evening
const dayCarriers = (mergedItemsMap[day.id] || []).filter(i => i.type === 'transport' && isCarrierTransport(i.data))
const dayHasLocatedCarrier = dayCarriers.some(i => hasCarrierEndpointOnDay(i.data, day.id))
const hasHotelTransfer = !!( const hasHotelTransfer = !!(
routeBookends?.morningIsSleptHere && routeBookends?.eveningIsOvernight && routeBookends?.morningIsSleptHere && routeBookends?.eveningIsOvernight &&
transferMorning?.place_lat != null && transferMorning?.place_lng != null && transferMorning?.place_lat != null && transferMorning?.place_lng != null &&
transferEvening?.place_lat != null && transferEvening?.place_lng != null && transferEvening?.place_lat != null && transferEvening?.place_lng != null &&
(transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng) (transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng) &&
(dayCarriers.length === 0 || dayHasLocatedCarrier)
) )
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer
/** /**
@@ -1613,21 +1671,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
*/ */
const dayExportStops = (): NamedWaypoint[] => { const dayExportStops = (): NamedWaypoint[] => {
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null) const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
// A flight, train, ferry or coach on a day without stops is the move itself,
// located or not: the hotels at either end are joined by it, not by a road
// worth handing to a map app (#2476).
if (dayStops.length === 0 && dayCarriers.length > 0) return []
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name })) const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined
const lastAssignment = dayStops[dayStops.length - 1] const lastAssignment = dayStops[dayStops.length - 1]
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined
// Same carrier gate as the drawn route (#2157): the exported link must not // Same carrier gate as the drawn route (#2157): the exported link must not
// start at a hotel you only reach tonight or lead back to one you left. // start at a hotel you only reach tonight or lead back to one you left.
const dayHasCarrier = (mergedItemsMap[day.id] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, day.id)) const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasLocatedCarrier)
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasCarrier) const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasLocatedCarrier)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasCarrier)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null ? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null ? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])] return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
} }
const showRouteTools = (isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable
// Built once, for the day the tools show on. With no stop to hand over the
// hand-offs would open nothing, so they are left out; a single stop still
// opens as a pin (#2476).
const exportStops = showRouteTools ? dayExportStops() : []
// Is this day's inline route currently on? Mobile toggles it per day (its // Is this day's inline route currently on? Mobile toggles it per day (its
// own expandedRouteDayIds entry); desktop uses the global Route toggle on // own expandedRouteDayIds entry); desktop uses the global Route toggle on
// the selected day (#1374). // the selected day (#1374).
@@ -1871,7 +1937,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromReservationId) { } else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex) handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
} else if (assignmentId && fromDayId !== day.id) { } else if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) moveToDay(Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (assignmentId) { } else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex) handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
} else if (noteId && fromDayId !== day.id) { } else if (noteId && fromDayId !== day.id) {
@@ -1894,7 +1960,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
setDropTargetKey(null); window.__dragData = null; return setDropTargetKey(null); window.__dragData = null; return
} }
if (assignmentId && fromDayId !== day.id) { if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) moveToDay(Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
if (noteId && fromDayId !== day.id) { if (noteId && fromDayId !== day.id) {
@@ -2054,7 +2120,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromReservationId) { } else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id) handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, assignment)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) moveToDay(Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, assignment)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -2097,13 +2163,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// does not exist here. // does not exist here.
const navTargets = getNavigationTargets(place) const navTargets = getNavigationTargets(place)
ctxMenu.open(e, [ ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, canEditPlaces && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') }, safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })), ...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) }, collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true }, { divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, canEditPlaces && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
]) ])
}} }}
onMouseEnter={e => { onMouseEnter={e => {
@@ -2478,7 +2544,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromReservationId) { } else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null) handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) moveToDay(Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
} else if (noteId && fromDayId !== day.id) { } else if (noteId && fromDayId !== day.id) {
@@ -2662,7 +2728,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromNoteId && fromNoteId !== String(note.id)) { } else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id) handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id))).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) moveToDay(Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id))).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null) setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
@@ -2773,7 +2839,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} }
if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return } if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) { if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) moveToDay(Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
if (noteId && fromDayId !== day.id) { if (noteId && fromDayId !== day.id) {
@@ -2798,7 +2864,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div> </div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend #1330 — oder Hotel-zu-Hotel-Transfertag ohne Orte #1297) */} {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend #1330 — oder Hotel-zu-Hotel-Transfertag ohne Orte #1297) */}
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && ( {showRouteTools && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button type="button" <button type="button"
@@ -2831,10 +2897,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{!narrowPanel && t('dayplan.route')} {!narrowPanel && t('dayplan.route')}
</button> </button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */} {/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<Tooltip label={t('planner.openGoogleMaps')} placement="top"> {exportStops.length > 0 && <Tooltip label={t('planner.openGoogleMaps')} placement="top">
<button type="button" <button type="button"
onClick={() => { onClick={() => {
const url = generateGoogleMapsUrl(dayExportStops()) const url = generateGoogleMapsUrl(exportStops)
if (url) window.open(url, '_blank', 'noopener,noreferrer') if (url) window.open(url, '_blank', 'noopener,noreferrer')
}} }}
aria-label={t('planner.openGoogleMaps')} aria-label={t('planner.openGoogleMaps')}
@@ -2847,14 +2913,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
> >
<GoogleMapsIcon size={14} /> <GoogleMapsIcon size={14} />
</button> </button>
</Tooltip> </Tooltip>}
{/* The same day, handed to CoMaps for offline navigation (#1904). The {/* The same day, handed to CoMaps for offline navigation (#1904). The
day's own travel mode rides along, so the route it builds walks day's own travel mode rides along, so the route it builds walks
when the plan walks. */} when the plan walks. */}
<Tooltip label={t('planner.openCoMaps')} placement="top"> {exportStops.length > 0 && <Tooltip label={t('planner.openCoMaps')} placement="top">
<button type="button" <button type="button"
onClick={() => { onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile) const url = generateCoMapsUrl(exportStops, day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer') if (url) window.open(url, '_blank', 'noopener,noreferrer')
}} }}
aria-label={t('planner.openCoMaps')} aria-label={t('planner.openCoMaps')}
@@ -2867,7 +2933,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
> >
<Compass size={14} strokeWidth={2} /> <Compass size={14} strokeWidth={2} />
</button> </button>
</Tooltip> </Tooltip>}
{/* Icon-only, like the two map hand-offs beside it (#1981). It {/* Icon-only, like the two map hand-offs beside it (#1981). It
was the one button here carrying a label with no room for was the one button here carrying a label with no room for
it: `flex: 1` alongside `padding: '6px 0'` meant the text it: `flex: 1` alongside `padding: '6px 0'` meant the text
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023 // FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF' import { downloadTripPDF } from '../PDF/TripPDF'
@@ -233,6 +233,48 @@ describe('DayPlanSidebarToolbar', () => {
expect(onAddDay).toHaveBeenCalledWith() expect(onAddDay).toHaveBeenCalledWith()
}) })
it('FE-PLANNER-DPTOOLBAR-024: the popup hands a delete request for its day to the planner', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
const onDeleteDay = vi.fn((_id: number) => {})
render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay })} />)
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
expect(onDeleteDay).toHaveBeenCalledWith(11)
})
it('FE-PLANNER-DPTOOLBAR-026: the planner question reaches the popup, which asks it in place of its list', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
const onCancel = vi.fn()
const question = { dayId: 11, title: 'Delete Day 2?', lines: [], onCancel, onConfirm: vi.fn() }
const props = makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay: vi.fn() })
const { rerender } = render(<DayPlanSidebarToolbar {...props} />)
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
rerender(<DayPlanSidebarToolbar {...props} deleteDayQuestion={question} />)
expect(screen.getByRole('region', { name: 'Delete Day 2?' })).toBeInTheDocument()
expect(screen.queryByText('dayplan.reorderHint')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(onCancel).toHaveBeenCalled()
})
it('FE-PLANNER-DPTOOLBAR-025: the popup gets the planner add controls, and its dated button adds the next date', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })]
const onAddDated = vi.fn()
const onAddDay = vi.fn()
render(<DayPlanSidebarToolbar {...makeProps({
days, canEditDays: true, onReorderDays: vi.fn(), onAddDay,
dayAdd: { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated },
})} />)
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
await user.click(screen.getByRole('button', { name: /^dayplan\.addDatedDay/ }))
expect(onAddDated).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
// The popup asks for an append, never a position.
expect(onAddDay).toHaveBeenCalledWith()
})
it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => { it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => {
const days = [buildDay({ id: 10 })] const days = [buildDay({ id: 10 })]
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />) const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
@@ -5,6 +5,8 @@ import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { TripExportModal } from './TripExportModal' import { TripExportModal } from './TripExportModal'
import { isRoutableReservation } from '../../utils/reservationRoutes' import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
interface DayPlanSidebarToolbarProps { interface DayPlanSidebarToolbarProps {
@@ -37,6 +39,12 @@ interface DayPlanSidebarToolbarProps {
canManageShare?: boolean canManageShare?: boolean
onReorderDays?: (orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void onAddDay?: (position?: number) => void
/** The planner's add controls: on a trip with dates the dialog offers the next date as well. */
dayAdd?: DayAddControls
/** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */
onDeleteDay?: (dayId: number) => void
/** The open delete question; the dialog asks it in place of its day list. */
deleteDayQuestion?: DayDeleteQuestion | null
} }
export function DayPlanSidebarToolbar({ export function DayPlanSidebarToolbar({
@@ -44,7 +52,7 @@ export function DayPlanSidebarToolbar({
allConnectionsShown = false, onToggleAllConnections, allConnectionsShown = false, onToggleAllConnections,
t, locale, toast, t, locale, toast,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, canManageShare = true, onReorderDays, onAddDay, canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
}: DayPlanSidebarToolbarProps) { }: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false) const [reorderOpen, setReorderOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
@@ -197,6 +205,9 @@ export function DayPlanSidebarToolbar({
locale={locale} locale={locale}
onReorder={onReorderDays} onReorder={onReorderDays}
onAddDay={() => onAddDay()} onAddDay={() => onAddDay()}
dayAdd={dayAdd}
onDeleteDay={onDeleteDay}
deleteQuestion={deleteDayQuestion}
onClose={() => setReorderOpen(false)} onClose={() => setReorderOpen(false)}
/> />
</div> </div>
@@ -1,8 +1,12 @@
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012 // FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-028
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MapPin } from 'lucide-react'
import { buildDay } from '../../../tests/helpers/factories' import { buildDay } from '../../../tests/helpers/factories'
import { DayReorderPopup } from './DayReorderPopup' import { DayReorderPopup } from './DayReorderPopup'
import { setForcedOffline } from '../../sync/networkMode'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Day } from '../../types' import type { Day } from '../../types'
// The component takes `t` as a prop, so returning the key keeps assertions exact. // The component takes `t` as a prop, so returning the key keeps assertions exact.
@@ -121,9 +125,9 @@ describe('DayReorderPopup', () => {
fireEvent.dragStart(first) fireEvent.dragStart(first)
fireEvent.dragOver(second) fireEvent.dragOver(second)
// The hovered row is highlighted while a drag is in flight. // The hovered row is highlighted while a drag is in flight.
expect(second.style.outline).toContain('dashed') expect(second).toHaveClass('outline-dashed')
fireEvent.dragEnd(first) fireEvent.dragEnd(first)
expect(second.style.outline).toBe('none') expect(second).not.toHaveClass('outline-dashed')
expect(onReorder).not.toHaveBeenCalled() expect(onReorder).not.toHaveBeenCalled()
}) })
@@ -137,4 +141,225 @@ describe('DayReorderPopup', () => {
await user.click(screen.getByText('common.close')) await user.click(screen.getByText('common.close'))
expect(onClose).toHaveBeenCalled() expect(onClose).toHaveBeenCalled()
}) })
describe('deleting a day', () => {
afterEach(() => setForcedOffline(false))
it('FE-PLANNER-DAYREORDER-013: each row asks to delete its own day, and only asks', async () => {
const user = userEvent.setup()
const onDeleteDay = vi.fn()
const onReorder = vi.fn()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay, onReorder })} />)
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
expect(buttons).toHaveLength(3)
await user.click(buttons[1])
expect(onDeleteDay).toHaveBeenCalledWith(7)
expect(onReorder).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-014: without a delete handler the rows carry no delete button', () => {
render(<DayReorderPopup {...makeProps({ days: threeDays() })} />)
expect(screen.queryByRole('button', { name: 'dayplan.deleteDay' })).not.toBeInTheDocument()
// The label is still the row's second span, next to its position badge.
expect(rows()[0].querySelectorAll('span')[1].textContent).toBe('Paris')
})
it('FE-PLANNER-DAYREORDER-015: the only day of a trip cannot be deleted', async () => {
const user = userEvent.setup()
const onDeleteDay = vi.fn()
render(<DayReorderPopup {...makeProps({ days: [buildDay({ id: 3, title: 'Paris', day_number: 1 })], onDeleteDay })} />)
const button = screen.getByRole('button', { name: 'dayplan.deleteDay' })
expect(button).toBeDisabled()
await user.click(button)
expect(onDeleteDay).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-016: offline every delete button is off, and hovering says why', async () => {
setForcedOffline(true)
const user = userEvent.setup()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn() })} />)
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true)
await user.hover(buttons[0].parentElement as HTMLElement)
expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument()
})
})
describe('adding a day', () => {
const tWithDate = (key: string, params?: Record<string, unknown>) => (params?.date ? `${key}|${params.date}` : key)
const controls = (overrides: Partial<DayAddControls> = {}): DayAddControls => ({
nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides,
})
it('FE-PLANNER-DAYREORDER-017: a trip with dates offers a day without a date and the next date, by name', () => {
render(<DayReorderPopup {...makeProps({ days: threeDays(), t: tWithDate, dayAdd: controls() })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
expect(screen.getByRole('button', { name: /^dayplan\.addDatedDay\|.*Oct 13/ })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'dayplan.addDay' })).not.toBeInTheDocument()
expect(screen.getByText(/^dayplan\.addDatedDayHint\|.*Oct 13/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-018: each button adds its own kind of day', async () => {
const user = userEvent.setup()
const onAddDay = vi.fn()
const dayAdd = controls()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd })} />)
await user.click(screen.getByRole('button', { name: 'dayplan.addUndatedDay' }))
expect(onAddDay).toHaveBeenCalledTimes(1)
expect(dayAdd.onAddDated).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'dayplan.addDatedDay' }))
expect(dayAdd.onAddDated).toHaveBeenCalledTimes(1)
expect(onAddDay).toHaveBeenCalledTimes(1)
})
it('FE-PLANNER-DAYREORDER-019: a trip without dates keeps the single button, which waits for the connection', async () => {
const user = userEvent.setup()
const onAddDay = vi.fn()
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null }) })} />)
expect(screen.queryByRole('button', { name: 'dayplan.addDatedDay' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dayplan.addDay' }))
expect(onAddDay).toHaveBeenCalledTimes(1)
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), onAddDay, dayAdd: controls({ nextDate: null, blocked: 'dayplan.daysOffline' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addDay' })).toBeDisabled()
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-020: busy or offline turns both off, a trip at the day limit only the dated one, and the line says why', () => {
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ busy: true }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ blocked: 'dayplan.daysOffline' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
expect(screen.getByText('dayplan.daysOffline')).toBeInTheDocument()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls({ datedBlocked: 'dashboard.tripTooLong' }) })} />)
expect(screen.getByRole('button', { name: 'dayplan.addUndatedDay' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'dayplan.addDatedDay' })).toBeDisabled()
expect(screen.getByText('dashboard.tripTooLong')).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-021: the line under the buttons follows the pointer and the focus onto the undated one', async () => {
const user = userEvent.setup()
render(<DayReorderPopup {...makeProps({ days: threeDays(), dayAdd: controls() })} />)
const undated = screen.getByRole('button', { name: 'dayplan.addUndatedDay' })
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
await user.hover(undated)
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
await user.unhover(undated)
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
fireEvent.focus(undated)
expect(screen.getByText('dayplan.addUndatedDayHint')).toBeInTheDocument()
fireEvent.blur(undated)
expect(screen.getByText('dayplan.addDatedDayHint')).toBeInTheDocument()
// Both buttons are described by that line.
const hintId = screen.getByText('dayplan.addDatedDayHint').id
expect(undated).toHaveAttribute('aria-describedby', hintId)
})
})
describe('the delete question, asked in place of the list', () => {
const question = (overrides: Partial<DayDeleteQuestion> = {}): DayDeleteQuestion => ({
dayId: 7,
title: 'Delete Lyon?',
lines: [{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 2', hint: 'They stay in the place list.' }],
onCancel: vi.fn(),
onConfirm: vi.fn(),
...overrides,
})
it('FE-PLANNER-DAYREORDER-022: the question takes the place of the rows and the add buttons, in the same dialog', () => {
const dayAdd: DayAddControls = { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn() }
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: question(), dayAdd })} />)
expect(rows()).toHaveLength(0)
expect(screen.queryByRole('button', { name: 'dayplan.addUndatedDay' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.close' })).not.toBeInTheDocument()
// Still the reorder dialog: its own title stays, the question sits under it.
expect(screen.getByText('dayplan.reorderTitle')).toBeInTheDocument()
const section = screen.getByRole('region', { name: 'Delete Lyon?' })
expect(section).toHaveAccessibleDescription('dayplan.deleteDayBody')
expect(screen.getByRole('list', { name: 'Delete Lyon?' })).toHaveTextContent('Planned places: 2')
expect(screen.getByRole('button', { name: 'common.cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dayplan.deleteDay' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-023: Cancel takes the question back, the delete button answers it', async () => {
const user = userEvent.setup()
const q = question()
const onClose = vi.fn()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(q.onCancel).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'dayplan.deleteDay' }))
expect(q.onConfirm).toHaveBeenCalledTimes(1)
})
it('FE-PLANNER-DAYREORDER-024: Escape takes back only the question, not the dialog', async () => {
const user = userEvent.setup()
const q = question()
const onClose = vi.fn()
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
await user.keyboard('{Escape}')
expect(q.onCancel).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-025: closing the dialog also drops an open question', async () => {
const user = userEvent.setup()
const q = question()
const onClose = vi.fn()
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
const closeX = document.querySelector<HTMLButtonElement>('h2 + button')
expect(closeX).not.toBeNull()
await user.click(closeX as HTMLButtonElement)
expect(q.onCancel).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
// A dialog closed from outside takes its question with it as well.
const again = question()
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: again, onClose })} />)
rerender(<DayReorderPopup {...makeProps({ isOpen: false, days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: again, onClose })} />)
expect(again.onCancel).toHaveBeenCalled()
})
it('FE-PLANNER-DAYREORDER-026: a question about a day that is no longer there is not asked', () => {
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: question({ dayId: 99 }) })} />)
expect(rows()).toHaveLength(3)
expect(screen.queryByRole('region', { name: 'Delete Lyon?' })).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYREORDER-027: the focus goes to Cancel, and back to the row asked about when the list returns', async () => {
const user = userEvent.setup()
const days = threeDays()
const onDeleteDay = vi.fn()
const { rerender } = render(<DayReorderPopup {...makeProps({ days, onDeleteDay })} />)
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
expect(onDeleteDay).toHaveBeenCalledWith(7)
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay, deleteQuestion: question() })} />)
expect(screen.getByRole('button', { name: 'common.cancel' })).toHaveFocus()
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay, deleteQuestion: null })} />)
expect(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]).toHaveFocus()
})
it('FE-PLANNER-DAYREORDER-028: after the day went, the focus lands on the row that took its place', async () => {
const user = userEvent.setup()
const days = threeDays()
const { rerender } = render(<DayReorderPopup {...makeProps({ days, onDeleteDay: vi.fn() })} />)
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay: vi.fn(), deleteQuestion: question() })} />)
const left = days.filter(d => d.id !== 7)
rerender(<DayReorderPopup {...makeProps({ days: left, onDeleteDay: vi.fn(), deleteQuestion: null })} />)
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
expect(buttons).toHaveLength(2)
expect(buttons[1]).toHaveFocus()
})
})
}) })
+203 -110
View File
@@ -1,6 +1,14 @@
import { useState } from 'react' import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react' import { GripVertical, ArrowUp, ArrowDown, Trash2, AlertTriangle } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import Tooltip from '../shared/Tooltip'
import DayImpactList from '../shared/DayImpactList'
import { DayAddFooter, FOOTER_BTN, SECONDARY_BTN } from './DayAddFooter'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { dayLabel } from '../../utils/dayLabel'
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Day } from '../../types' import type { Day } from '../../types'
interface DayReorderPopupProps { interface DayReorderPopupProps {
@@ -10,29 +18,56 @@ interface DayReorderPopupProps {
locale: string locale: string
onReorder: (orderedIds: number[]) => void onReorder: (orderedIds: number[]) => void
onAddDay: () => void onAddDay: () => void
/**
* The planner's add controls. On a trip with dates they add a second button for
* the next calendar day; without them the footer keeps its single "Add day".
*/
dayAdd?: DayAddControls
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
onDeleteDay?: (dayId: number) => void
/** The open delete question, asked in place of the day list until it is answered. */
deleteQuestion?: DayDeleteQuestion | null
onClose: () => void onClose: () => void
} }
/** Where the list stood when the question opened, to come back to it. */
interface ListSpot {
dayId: number
index: number
scrollTop: number
}
const ICON_BTN =
'grid h-7 w-7 flex-shrink-0 place-items-center rounded-md border border-edge-faint text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted'
const DELETE_BTN =
'grid h-7 w-7 place-items-center rounded-md text-content-faint transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-content-faint'
/** /**
* Modal for moving whole days around: drag a row by its grip or use the up/down * Modal for moving whole days around: drag a row by its grip or use the up/down
* arrows, and add a day at the end. Day headers stay untouched — this is the * arrows, add a day, or delete one. Day headers stay untouched, so this is the
* single surface for ordering. Reorders are applied optimistically by the store, * single surface for ordering. Reorders are applied optimistically by the store,
* so the list reflects each move immediately. * so the list reflects each move immediately.
*
* A delete only asks, and it asks right here: the dialog swaps its list for the
* question, what goes with the day and the two ways out, at the same width. The
* dialog hangs from a fixed top edge, so the swap only moves its bottom edge,
* and the question takes the height it needs. Cancel and Escape bring the list
* back where it was.
*/ */
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) { export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, deleteQuestion, onClose }: DayReorderPopupProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null) const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null) const [overIndex, setOverIndex] = useState<number | null>(null)
const { offline } = useNetworkMode()
const bodyRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
const spot = useRef<ListSpot | null>(null)
const titleId = useId()
const bodyId = useId()
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t)
const label = (day: Day, index: number) => { // A question about a day that is gone (deleted elsewhere) is no question.
if (day.title) return day.title const asking = isOpen && deleteQuestion && ordered.some(d => d.id === deleteQuestion.dayId) ? deleteQuestion : null
if (day.date) {
const d = new Date(day.date + 'T00:00:00')
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
return t('dayplan.dayN', { n: index + 1 })
}
const move = (from: number, to: number) => { const move = (from: number, to: number) => {
if (to < 0 || to >= ordered.length || from === to) return if (to < 0 || to >= ordered.length || from === to) return
@@ -42,106 +77,164 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
onReorder(ids) onReorder(ids)
} }
const cellBtn = { const ask = (dayId: number, index: number) => {
display: 'grid', placeItems: 'center', width: 28, height: 28, spot.current = { dayId, index, scrollTop: bodyRef.current?.parentElement?.scrollTop ?? 0 }
border: '1px solid var(--border-faint)', borderRadius: 7, onDeleteDay?.(dayId)
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0, }
} as const
// Closing the dialog also drops a question left open in it; so does the
// dialog going away with the sidebar that holds it.
const questionRef = useRef(deleteQuestion)
questionRef.current = deleteQuestion
const close = () => { questionRef.current?.onCancel(); onClose() }
useEffect(() => { if (!isOpen) questionRef.current?.onCancel() }, [isOpen])
useEffect(() => () => questionRef.current?.onCancel(), [])
// Escape takes back the question, not the dialog. Captured and stopped
// before the dialog's own listener, which would close everything.
useEffect(() => {
if (!asking) return
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
asking.onCancel()
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [asking])
// The question opens at its top with the focus on Cancel. Back at the list,
// the scroll position and the focus return to the row asked about.
const askedDayId = asking?.dayId ?? null
const shownDayId = useRef<number | null>(null)
useLayoutEffect(() => {
const body = bodyRef.current
// Closed with the question open: the next opening starts at the list, fresh.
if (!body) { shownDayId.current = null; spot.current = null; return }
if (shownDayId.current === askedDayId) return
shownDayId.current = askedDayId
const scroller = body.parentElement
if (askedDayId != null) {
if (scroller) scroller.scrollTop = 0
cancelRef.current?.focus()
return
}
const from = spot.current
if (!from) return
spot.current = null
if (scroller) scroller.scrollTop = from.scrollTop
const buttons = body.querySelectorAll<HTMLButtonElement>('[data-delete-day]')
const again = body.querySelector<HTMLButtonElement>(`[data-delete-day="${from.dayId}"]`)
?? buttons[Math.min(from.index, buttons.length - 1)]
again?.focus()
}, [askedDayId])
const footer = asking ? (
<div className="flex items-center justify-end gap-2">
<button ref={cancelRef} type="button" onClick={asking.onCancel} className={SECONDARY_BTN}>
{t('common.cancel')}
</button>
<button type="button" onClick={asking.onConfirm} className={`${FOOTER_BTN} bg-danger font-semibold text-white hover:opacity-90`}>
<Trash2 size={15} strokeWidth={2} aria-hidden="true" />
{t('dayplan.deleteDay')}
</button>
</div>
) : (
<DayAddFooter dayAdd={dayAdd} onAddDay={onAddDay} onClose={close} t={t} locale={locale} />
)
return ( return (
<Modal <Modal isOpen={isOpen} onClose={close} title={t('dayplan.reorderTitle')} size="lg" align="top" footer={footer}>
isOpen={isOpen} <div ref={bodyRef}>
onClose={onClose} {asking ? (
title={t('dayplan.reorderTitle')} <section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
size="md" <div className="flex items-start gap-3">
footer={ <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-danger-soft text-danger">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <AlertTriangle size={16} strokeWidth={2} aria-hidden="true" />
<button type="button" </span>
onClick={onClose} <div className="min-w-0 flex-1">
style={{ <h3 id={titleId} className="text-subtitle font-semibold leading-snug text-content">{asking.title}</h3>
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, <p id={bodyId} className="mt-0.5 text-caption text-content-muted">{t('dayplan.deleteDayBody')}</p>
border: '1px solid var(--border-primary)', background: 'none', </div>
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit', </div>
}} <DayImpactList lines={asking.lines} label={asking.title} />
> </section>
{t('common.close')} ) : (
</button> <>
<button type="button" <p className="mb-3 text-caption text-content-muted">{t('dayplan.reorderHint')}</p>
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={15} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
}
>
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
{t('dayplan.reorderHint')}
</p>
{/* The popup is a modal, so it portals out of the planner and has to opt {/* The popup is a modal, so it portals out of the planner and has to opt
into the long-press drag itself (#1616). Without this a finger only into the long-press drag itself (#1616). Without this a finger only
selects the row text. */} selects the row text. */}
<div data-touch-drag style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div data-touch-drag className="flex flex-col gap-1">
{ordered.map((day, index) => ( {ordered.map((day, index) => {
<div const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index
key={day.id} return (
draggable <div
onDragStart={() => setDragIndex(index)} key={day.id}
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }} draggable
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }} onDragStart={() => setDragIndex(index)}
onDrop={e => { onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
e.preventDefault() onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index) onDrop={e => {
setDragIndex(null); setOverIndex(null) e.preventDefault()
}} if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
style={{ setDragIndex(null); setOverIndex(null)
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', }}
borderRadius: 9, className={`flex items-center gap-2.5 rounded-lg border border-edge-faint px-2.5 py-1.5 ${
border: '1px solid var(--border-faint)', dropTarget ? 'bg-surface-hover outline-dashed outline-2 -outline-offset-2 outline-edge' : 'bg-surface-card'
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)', } ${dragIndex === index ? 'opacity-50' : ''}`}
opacity: dragIndex === index ? 0.5 : 1, >
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none', <GripVertical size={15} strokeWidth={1.8} className="flex-shrink-0 cursor-grab text-content-faint" />
outlineOffset: -2, <span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-full bg-surface-hover text-caption font-semibold text-content-muted">
}} {index + 1}
> </span>
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} /> <span className="min-w-0 flex-1 truncate text-body font-medium text-content">
<span style={{ {dayLabel(day, index, t, locale)}
flexShrink: 0, width: 24, height: 24, borderRadius: '50%', </span>
background: 'var(--bg-hover)', color: 'var(--text-muted)', <button
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700, type="button"
}}> onClick={() => move(index, index - 1)}
{index + 1} disabled={index === 0}
</span> aria-label={t('dayplan.moveUp')}
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> className={ICON_BTN}
{label(day, index)} >
</span> <ArrowUp size={14} strokeWidth={2} />
<button </button>
type="button" <button
onClick={() => move(index, index - 1)} type="button"
disabled={index === 0} onClick={() => move(index, index + 1)}
aria-label={t('dayplan.moveUp')} disabled={index === ordered.length - 1}
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }} aria-label={t('dayplan.moveDown')}
> className={ICON_BTN}
<ArrowUp size={14} strokeWidth={2} /> >
</button> <ArrowDown size={14} strokeWidth={2} />
<button </button>
type="button" {onDeleteDay && (
onClick={() => move(index, index + 1)} <>
disabled={index === ordered.length - 1} <span aria-hidden="true" className="h-5 w-px flex-shrink-0 bg-edge-faint" />
aria-label={t('dayplan.moveDown')} <Tooltip label={deleteBlocked ?? t('dayplan.deleteDay')} placement="left">
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }} <span className="inline-flex flex-shrink-0">
> <button
<ArrowDown size={14} strokeWidth={2} /> type="button"
</button> data-delete-day={day.id}
</div> onClick={() => ask(day.id, index)}
))} disabled={!!deleteBlocked}
aria-label={t('dayplan.deleteDay')}
className={DELETE_BTN}
>
<Trash2 size={15} strokeWidth={2} />
</button>
</span>
</Tooltip>
</>
)}
</div>
)
})}
</div>
</>
)}
</div> </div>
</Modal> </Modal>
) )
@@ -1,4 +1,4 @@
// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-019 // FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-024
import { useState } from 'react'; import { useState } from 'react';
import { delay, http, HttpResponse } from 'msw'; import { delay, http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
@@ -29,11 +29,15 @@ function searchRoute(places: SearchHit[] | (() => Response | Promise<Response>))
// Controlled host — `value` drives the input text and mutes the search for the // Controlled host — `value` drives the input text and mutes the search for the
// already-picked name, exactly like ReservationModal wires it up. // already-picked name, exactly like ReservationModal wires it up.
function Host({ initial = null, onPick }: { initial?: LocationPoint | null; onPick?: (l: LocationPoint | null) => void }) { function Host({ initial = null, onPick, places }: { initial?: LocationPoint | null; onPick?: (l: LocationPoint | null) => void; places?: LocationPoint[] }) {
const [value, setValue] = useState<LocationPoint | null>(initial); const [value, setValue] = useState<LocationPoint | null>(initial);
return <LocationSelect value={value} onChange={(l) => { setValue(l); onPick?.(l); }} />; return <LocationSelect value={value} onChange={(l) => { setValue(l); onPick?.(l); }} places={places} />;
} }
/** The trip's own places, as the transport forms hand them over (#2468). */
const HOTEL = { name: 'Bach Suites Saigon', lat: 10.78, lng: 106.7, address: 'District 1' };
const EXPO = { name: 'Textile Exhibition', lat: 10.8, lng: 106.66, address: null };
describe('LocationSelect', () => { describe('LocationSelect', () => {
it('FE-PLANNER-LOCSEL-001: falls back to the translated placeholder', () => { it('FE-PLANNER-LOCSEL-001: falls back to the translated placeholder', () => {
render(<LocationSelect value={null} onChange={vi.fn()} />); render(<LocationSelect value={null} onChange={vi.fn()} />);
@@ -263,4 +267,77 @@ describe('LocationSelect', () => {
await waitFor(() => expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument());
}); });
it('FE-PLANNER-LOCSEL-020: an empty field offers the trip places, and picking one needs no search', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(searchRoute(() => { calls++; return HttpResponse.json({ places: [GARE] }); }));
const onPick = vi.fn();
render(<Host places={[HOTEL, EXPO]} onPick={onPick} />);
await user.click(screen.getByRole('textbox'));
expect(screen.getByText('Bach Suites Saigon')).toBeInTheDocument();
expect(screen.getByText('Textile Exhibition')).toBeInTheDocument();
await user.click(screen.getByText('Textile Exhibition'));
expect(onPick).toHaveBeenLastCalledWith({ name: 'Textile Exhibition', lat: 10.8, lng: 106.66, address: null });
await settle();
expect(calls).toBe(0);
});
it('FE-PLANNER-LOCSEL-021: one or two typed characters narrow the trip places by name', async () => {
const user = userEvent.setup();
render(<Host places={[HOTEL, EXPO]} />);
await user.type(screen.getByRole('textbox'), 'te');
expect(screen.getByText('Textile Exhibition')).toBeInTheDocument();
// "Bach Suites" has a "te" inside "Suites", so it stays; a name without it goes.
expect(screen.getByText('Bach Suites Saigon')).toBeInTheDocument();
await user.type(screen.getByRole('textbox'), '{backspace}{backspace}ex');
expect(screen.getByText('Textile Exhibition')).toBeInTheDocument();
expect(screen.queryByText('Bach Suites Saigon')).not.toBeInTheDocument();
});
it('FE-PLANNER-LOCSEL-022: from the third character on the map search answers instead of the trip places', async () => {
const user = userEvent.setup();
server.use(searchRoute([GARE]));
render(<Host places={[HOTEL, EXPO]} />);
await user.type(screen.getByRole('textbox'), 'Gare');
expect(await screen.findByText('Gare du Nord')).toBeInTheDocument();
expect(screen.queryByText('Bach Suites Saigon')).not.toBeInTheDocument();
});
it('FE-PLANNER-LOCSEL-023: a place already picked does not reopen the trip places when the field is focused', async () => {
const user = userEvent.setup();
render(<Host places={[HOTEL, EXPO]} initial={{ name: 'Ab', lat: 1, lng: 2 }} />);
await user.click(screen.getByRole('textbox'));
expect(screen.queryByText('Bach Suites Saigon')).not.toBeInTheDocument();
});
it('FE-PLANNER-LOCSEL-024: shortening the query after moving down the search results picks nothing stale', async () => {
const user = userEvent.setup();
server.use(searchRoute([GARE, LYON, { ...GARE, name: 'Gare de l Est', osm_id: 'n3' }]));
const onPick = vi.fn();
render(<Host places={[HOTEL, EXPO]} onPick={onPick} />);
const input = screen.getByRole('textbox');
await user.type(input, 'Gare');
await screen.findByText('Gare de Lyon');
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}');
// Back under three characters: the trip places are shown and nothing is highlighted,
// so Enter does nothing rather than reaching for the third row of the old list.
await user.type(input, '{backspace}{backspace}{backspace}');
await user.keyboard('{Enter}');
expect(onPick).not.toHaveBeenCalledWith(expect.objectContaining({ lat: expect.any(Number) }));
await user.keyboard('{ArrowDown}{Enter}');
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Bach Suites Saigon' }));
});
}); });
@@ -16,9 +16,15 @@ interface Props {
onChange: (loc: LocationPoint | null) => void onChange: (loc: LocationPoint | null) => void
placeholder?: string placeholder?: string
style?: React.CSSProperties style?: React.CSSProperties
/**
* The trip's own places, offered while the field is empty or holds fewer than three
* characters, the way the transit search offers them (#2468). Typing narrows them by
* name; from the third character on the map search answers instead.
*/
places?: LocationPoint[]
} }
export default function LocationSelect({ value, onChange, placeholder, style }: Props) { export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher. // Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias() const { point: locationBias } = useLocationBias()
@@ -81,11 +87,20 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
setResults([]) setResults([])
} }
// The trip's places stand in for the search below three characters. Derived rather
// than written into `results`, so a search answer that lands after the field was
// shortened cannot replace them, and a place already picked does not reopen them.
const typed = query.trim()
const showPicks = !value && typed.length < 3 && !!places?.length
const rows = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
: results
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => { const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || results.length === 0) return if (!open || rows.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) } if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, rows.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) } else if (e.key === 'Enter' && rows[highlight]) { e.preventDefault(); pick(rows[highlight]) }
else if (e.key === 'Escape') setOpen(false) else if (e.key === 'Escape') setOpen(false)
} }
@@ -97,7 +112,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
type="text" type="text"
value={query} value={query}
placeholder={placeholder ?? t('reservations.searchLocation')} placeholder={placeholder ?? t('reservations.searchLocation')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }} onChange={(e) => { setQuery(e.target.value); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onKeyDown={onKey} onKeyDown={onKey}
className="bg-transparent text-content" className="bg-transparent text-content"
@@ -110,14 +125,14 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
)} )}
</div> </div>
{open && (loading || results.length > 0) && ( {open && ((loading && !showPicks) || rows.length > 0) && (
<div className="bg-surface-card" style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}> <div className="bg-surface-card" style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
{loading && results.length === 0 && ( {loading && !showPicks && rows.length === 0 && (
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div> <div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div>
)} )}
{results.map((r, i) => ( {rows.map((r, i) => (
<button <button
key={`${r.osm_id || r.google_place_id || i}`} key={showPicks ? `pick:${r.name}:${r.lat}:${r.lng}` : `${r.osm_id || r.google_place_id || i}`}
type="button" type="button"
onClick={() => pick(r)} onClick={() => pick(r)}
onMouseEnter={() => setHighlight(i)} onMouseEnter={() => setHighlight(i)}
@@ -1,5 +1,5 @@
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -080 // FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -091
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import { render, screen, waitFor, fireEvent, within, act } 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 { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
@@ -162,6 +162,88 @@ describe('PlaceFormModal', () => {
expect(screen.queryByDisplayValue('Old Place')).not.toBeInTheDocument(); expect(screen.queryByDisplayValue('Old Place')).not.toBeInTheDocument();
}); });
it('FE-PLANNER-PLACEFORM-088: closing the dialog clears the search field', async () => {
const user = userEvent.setup();
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel Tower');
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.getByPlaceholderText('Search places...')).toHaveValue('');
});
it('FE-PLANNER-PLACEFORM-089: closing the dialog drops the result list and its Google line', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Search Google instead/ })).toBeInTheDocument();
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.getByPlaceholderText('Search places...')).toHaveValue('');
expect(screen.queryByText('Weigh station')).toBeNull();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-090: suggestions still on their way when the dialog closes never open over the next one', async () => {
const user = userEvent.setup();
const gate: { started: boolean; release?: () => void } = { started: false };
server.use(
http.post('/api/maps/autocomplete', async () => {
gate.started = true;
await new Promise<void>(res => { gate.release = res; });
return HttpResponse.json({ suggestions: [{ placeId: 'late-1', mainText: 'Late Suggestion', secondaryText: 'Nowhere' }], source: 'trek-places' });
}),
);
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Late');
await waitFor(() => expect(gate.started).toBe(true));
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
gate.release?.();
await act(async () => { await new Promise(res => setTimeout(res, 100)); });
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.queryByText('Late Suggestion')).toBeNull();
});
it('FE-PLANNER-PLACEFORM-091: a search that answers after the dialog closed leaves the next opening empty', async () => {
const user = userEvent.setup();
const gate: { started: boolean; release?: () => void } = { started: false };
server.use(
http.post('/api/maps/search', async () => {
gate.started = true;
await new Promise<void>(res => { gate.release = res; });
return HttpResponse.json({ places: [{ name: 'Late Result', address: 'Nowhere', lat: '1', lng: '2' }] });
}),
);
const { rerender } = render(<PlaceFormModal {...defaultProps} isOpen />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Late');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
await waitFor(() => expect(gate.started).toBe(true));
rerender(<PlaceFormModal {...defaultProps} isOpen={false} />);
gate.release?.();
await act(async () => { await new Promise(res => setTimeout(res, 100)); });
rerender(<PlaceFormModal {...defaultProps} isOpen />);
expect(screen.queryByText('Late Result')).toBeNull();
// The button is back to its icon rather than the "..." of a search in flight.
expect(within(screen.getByPlaceholderText('Search places...').closest('.flex') as HTMLElement).getByRole('button')).not.toHaveTextContent('...');
});
// ── Maps search ────────────────────────────────────────────────────────────── // ── Maps search ──────────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-018: maps search populates results via button click', async () => { it('FE-PLANNER-PLACEFORM-018: maps search populates results via button click', async () => {
@@ -233,6 +315,81 @@ describe('PlaceFormModal', () => {
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
}); });
it('FE-PLANNER-PLACEFORM-018d: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => {
// The server only honours the request while Google holds the keyed slot;
// under another provider the link would re-run the same search and stay.
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }),
),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull();
});
it('FE-PLANNER-PLACEFORM-018e: the link sends the query the list came from, not what the field holds by then', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })),
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// The list stays while the field is retyped; the line still means this list.
await user.clear(searchInput);
await user.type(searchInput, 'Kyoto');
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies).toHaveLength(2);
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-018f: the link still works after the field was cleared', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true });
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/maps/search', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Tokyo Station');
await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button'));
expect(await screen.findByText('Weigh station')).toBeInTheDocument();
// An empty field used to make the click a silent no-op.
await user.clear(searchInput);
await user.click(screen.getByRole('button', { name: /Search Google instead/ }));
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument();
expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' });
});
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => { 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(
@@ -18,7 +18,7 @@ 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 { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource' import { offersGoogleRetry, selectGoogleHoldsSlot, 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'
@@ -191,12 +191,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [acHighlight, setAcHighlight] = useState(-1) const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null) const acAbortRef = useRef<AbortController | null>(null)
// Counts the closings of this dialog, so an answer still on its way when it
// closed can tell that the opening it was asked for is over. A counter rather
// than an abort, because the full search takes no signal.
const searchEpochRef = useRef(0)
// Ties one search's keystrokes and its details lookup into a single Google // Ties one search's keystrokes and its details lookup into a single Google
// billing session (see utils/placesSession). // billing session (see utils/placesSession).
const placesSessionRef = useRef(new PlacesSession()) const placesSessionRef = useRef(new PlacesSession())
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore() const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const can = useCanDo() const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h' const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip) const tripObj = useTripStore((s) => s.trip)
@@ -317,6 +322,31 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [place, prefillCoords, isOpen, assignmentId]) }, [place, prefillCoords, isOpen, assignmentId])
// The planner keeps this dialog mounted while it is closed (Modal only stops
// drawing it), so everything the search block holds would greet the next
// opening: the last query, its list, the Google line offering that list's
// query again, suggestions still on their way. A pick from that list writes
// over the place being edited, whichever place that is by then. Reset when
// it closes, in an effect of its own: the one above also reruns while the
// dialog is open, when a right-click's reverse lookup fills prefillCoords,
// and must not wipe a search the user has started since.
useEffect(() => {
if (isOpen) return
searchEpochRef.current += 1
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
acAbortRef.current?.abort()
placesSessionRef.current.end()
searchMetaRef.current = null
acMetaRef.current = null
setMapsSearch('')
setMapsResults([])
setSearchSource('')
setAcSuggestions([])
setAcSource('')
setAcHighlight(-1)
setIsSearchingMaps(false)
}, [isOpen])
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setTimeout(() => { setTimeout(() => {
@@ -397,13 +427,18 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} }
const handleMapsSearch = async (provider?: 'google') => { const handleMapsSearch = async (provider?: 'google') => {
if (!mapsSearch.trim()) return // The retry sends the query the list came from, not the field: the list
// stays on screen while the field is edited or cleared, and the line under
// it promises the same query.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
if (!trimmed) return
const epoch = searchEpochRef.current
setIsSearchingMaps(true) setIsSearchingMaps(true)
try { try {
// A pasted Google Maps or Amap link resolves server-side into a place // A pasted Google Maps or Amap link resolves server-side into a place
const trimmed = mapsSearch.trim() if (!provider && isMapUrl(trimmed)) {
if (isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed) const resolved = await mapsApi.resolveUrl(trimmed)
if (epoch !== searchEpochRef.current) return
if (resolved.lat && resolved.lng) { if (resolved.lat && resolved.lng) {
setForm(prev => ({ setForm(prev => ({
...prev, ...prev,
@@ -419,14 +454,16 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return return
} }
} }
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider) const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' } if (epoch !== searchEpochRef.current) return
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setMapsResults(result.places || []) setMapsResults(result.places || [])
setSearchSource(result.source || '') setSearchSource(result.source || '')
} catch (err: unknown) { } catch (err: unknown) {
if (epoch !== searchEpochRef.current) return
toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally { } finally {
setIsSearchingMaps(false) if (epoch === searchEpochRef.current) setIsSearchingMaps(false)
} }
} }
@@ -486,6 +523,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setAcSuggestions([]) setAcSuggestions([])
setAcHighlight(-1) setAcHighlight(-1)
const previousSearch = mapsSearch const previousSearch = mapsSearch
const epoch = searchEpochRef.current
setMapsSearch('') setMapsSearch('')
setForm(prev => ({ ...prev, name: suggestion.mainText })) setForm(prev => ({ ...prev, name: suggestion.mainText }))
setIsSearchingMaps(true) setIsSearchingMaps(true)
@@ -507,6 +545,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} catch (err) { } catch (err) {
console.error('Failed to fetch place details:', err) console.error('Failed to fetch place details:', err)
} }
// Closed while the details were on their way: the pick belongs to an
// opening that is over, and the fallback search below is not worth a
// request nobody will see.
if (epoch !== searchEpochRef.current) return
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) { if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's rows carry no address; their second line is the name // The layer's rows carry no address; their second line is the name
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅" // written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
@@ -525,6 +567,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
if (!place) { if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ') const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language, locationBiasPoint) const search = await mapsApi.search(query, language, locationBiasPoint)
if (epoch !== searchEpochRef.current) return
place = search.places?.[0] ?? null place = search.places?.[0] ?? null
} }
if (place) { if (place) {
@@ -534,12 +577,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
toast.error(t('places.mapsSearchError')) toast.error(t('places.mapsSearchError'))
} }
} catch (err) { } catch (err) {
if (epoch !== searchEpochRef.current) return
console.error('Place suggestion lookup failed:', err) console.error('Place suggestion lookup failed:', err)
setMapsSearch(previousSearch) setMapsSearch(previousSearch)
toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally { } finally {
setIsSearchingMaps(false) // Closing already ended this session; ending it again here would cut off
placesSessionRef.current.end() // the one a new opening may have started meanwhile.
if (epoch === searchEpochRef.current) {
setIsSearchingMaps(false)
placesSessionRef.current.end()
}
} }
} }
@@ -760,7 +808,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language, language,
locale, locale,
timeFormat, timeFormat,
hasMapsKey, googleAnswers,
placesEnrichEnabled, placesEnrichEnabled,
can, can,
tripObj, tripObj,
@@ -838,7 +886,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast, toast,
t, t,
language, language,
hasMapsKey, googleAnswers,
placesEnrichEnabled, placesEnrichEnabled,
can, can,
tripObj, tripObj,
@@ -1005,8 +1053,9 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
{/* The index answers first and Google only when it finds nothing, so a {/* 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 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 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. */} where Google holds the key slot and for a list Google did not
{mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && ( already produce. */}
{mapsResults.length > 0 && offersGoogleRetry(searchSource, googleAnswers) && (
<button <button
type="button" type="button"
onClick={() => handleMapsSearch('google')} onClick={() => handleMapsSearch('google')}
@@ -1147,9 +1196,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
options={[ options={[
{ value: '', label: t('places.noCategory') }, { value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({ ...(categories || []).map(c => ({
// form.category_id is a string; CustomSelect matches options by // A string like form.category_id, so the picked option hands
// strict equality, so the option value must be a string too — // back the same kind of value the form already keeps.
// otherwise the chosen category never renders in the trigger.
value: String(c.id), value: String(c.id),
label: c.name, label: c.name,
})), })),
@@ -8,9 +8,11 @@ import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore'; import { useAddonStore } from '../../store/addonStore';
import { usePluginStore } from '../../store/pluginStore'; import { usePluginStore } from '../../store/pluginStore';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'; import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
import type { AssignmentsMap } from '../../types'; import type { AssignmentsMap } from '../../types';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
// ── Module mocks ────────────────────────────────────────────────────────────── // ── Module mocks ──────────────────────────────────────────────────────────────
@@ -1478,6 +1480,25 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press'); expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
}); });
it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', () => {
// buildUser() is a plain member and buildTrip({ id: 1 }) belongs to somebody else.
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
const onEdit = vi.fn();
const onDelete = vi.fn();
const onUpdatePlace = vi.fn();
const member = render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
fireEvent.doubleClick(screen.getByText(place.name));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
member.unmount();
// the same trip owned by this member shows them again
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) });
render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => { it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => {
// Taking it off the day would leave the booking behind with nothing on the drive and // Taking it off the day would leave the booking behind with nothing on the drive and
@@ -1545,3 +1566,25 @@ describe('PlaceInspector', () => {
expect(screen.queryByText('Remove from Day')).toBeNull(); expect(screen.queryByText('Remove from Day')).toBeNull();
}); });
}) })
// ── Blur booking codes on the linked-booking strip (#2457) ────────────────────
describe('PlaceInspector blur booking codes (#2457)', () => {
const renderWithBooking = (blur: boolean) => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: blur } });
const res = { ...buildReservation({ id: 3, title: 'Dinner at Jules Verne', status: 'confirmed', confirmation_number: 'TABLE-SECRET' }), assignment_id: 9 };
render(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[res]} onEditReservation={vi.fn()} />);
};
it('FE-PLANNER-INSPECTOR-103: the booking code on the linked-booking strip is blurred while the setting is on', () => {
renderWithBooking(true);
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(true);
});
it('FE-PLANNER-INSPECTOR-104: with the setting off the strip shows the code plainly', () => {
renderWithBooking(false);
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(false);
});
});
@@ -10,6 +10,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react' import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload' import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating' import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker' import TrackColorPicker from '../shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors' import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
@@ -32,6 +33,7 @@ import type { Place, Category, Day, Assignment, Reservation, TripFile, Assignmen
import type { CollectionStatus } from '@trek/shared' import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters' import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { formatDistance, formatElevation } from '../../utils/units' import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation' import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge' import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
@@ -184,11 +186,21 @@ interface PlaceInspectorProps {
export default function PlaceInspector({ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null, place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, assignments = {}, reservations = [], onEditTransport, onEditReservation,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate, files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onRate,
leftWidth = 0, rightWidth = 0, leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) { }: PlaceInspectorProps) {
// Editing the place is a place right. The planner hands the handlers over
// regardless, and a member without the right saw Edit, Delete and the inline
// rename and got the server's 403 for each (#2446). Collection mode gates
// its own actions.
const can = useCanDo()
const trip = useTripStore(s => s.trip)
const mayEditPlace = mode !== 'trip' || can('place_edit', trip)
const onEdit = mayEditPlace ? editPlace : undefined
const onDelete = mayEditPlace ? deletePlace : undefined
const onUpdatePlace = mayEditPlace ? updatePlace : undefined
// Plugins that declared a place-detail slot mount at the bottom of this panel, // Plugins that declared a place-detail slot mount at the bottom of this panel,
// scoped to the open place (trip mode only). Inline-filter like the other sites. // scoped to the open place (trip mode only). Inline-filter like the other sites.
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail') const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
@@ -949,7 +961,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
{res.confirmation_number && ( {res.confirmation_number && (
<div> <div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div> <div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div> <div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}><BlurredCode>{res.confirmation_number}</BlurredCode></div>
</div> </div>
)} )}
</div> </div>
@@ -196,9 +196,9 @@ export function PlacesHeader(S: SidebarState) {
{/* Says out loud what the count above already narrowed to. {/* Says out loud what the count above already narrowed to.
The map has followed the open day on this filter since #2024, and until now The map has followed the open day on this filter since #2024, and until now
nothing anywhere said so: the pool read 55, the map drew five, and the honest nothing anywhere said so: the pool read 55, the map drew five, and the honest
conclusion was that the map was broken. It sits under the tabs rather than on conclusion was that the map was broken. It sits under the filter select
the map because a chip over the canvas is unreachable on a phone, which is rather than on the map because a chip over the canvas is unreachable on a
where this was reported from. */} phone, which is where this was reported from. */}
{dayScoped && ( {dayScoped && (
<div <div
className="border border-edge-faint bg-surface-tertiary text-content-secondary" className="border border-edge-faint bg-surface-tertiary text-content-secondary"
@@ -1,11 +1,13 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095 // FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-105
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import { render, screen, waitFor, fireEvent, within, act } 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 { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore'; import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { import {
buildUser, buildUser,
@@ -625,6 +627,29 @@ describe('ReservationModal', () => {
); );
}); });
it('FE-PLANNER-RESMODAL-101: an imported track is not offered as the place of a stay', async () => {
const hotel = buildPlace({ id: 21, name: 'Hotel Adler' });
const track = buildPlace({ id: 22, name: 'Rheinsteig', route_geometry: '[[50.1,7.6],[50.2,7.7]]' });
render(<ReservationModal {...defaultProps} places={[hotel, track]} />);
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
const field = screen.getAllByText('Accommodation').find(el => el.tagName === 'LABEL')!.parentElement!;
await userEvent.click(within(field).getByRole('button'));
expect(screen.getByText('Hotel Adler')).toBeInTheDocument();
expect(screen.queryByText('Rheinsteig')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-102: a stay already booked at a track still shows that track', () => {
const track = buildPlace({ id: 22, name: 'Rheinsteig', route_geometry: '[[50.1,7.6],[50.2,7.7]]' });
const days = [buildDay({ id: 1 }), buildDay({ id: 2 })];
const accommodations = [{ id: 7, trip_id: 1, place_id: 22, start_day_id: 1, end_day_id: 2 }];
const res = buildReservation({ id: 9, type: 'hotel', title: 'Hut', accommodation_id: 7 });
render(<ReservationModal {...defaultProps} days={days} places={[track]} accommodations={accommodations as never} reservation={res} />);
expect(screen.getByText('Rheinsteig')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-043: hover styles applied to file picker items', async () => { it('FE-PLANNER-RESMODAL-043: hover styles applied to file picker items', async () => {
const res = buildReservation({ id: 5 }); const res = buildReservation({ id: 5 });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
@@ -1001,7 +1026,7 @@ describe('ReservationModal', () => {
expect(screen.getByText('Linked expense')).toBeInTheDocument(); expect(screen.getByText('Linked expense')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation' }); expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation', currency: 'EUR' });
}); });
it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => { it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => {
@@ -1311,6 +1336,55 @@ describe('ReservationModal', () => {
delete window.__addToast; delete window.__addToast;
}); });
it('FE-PLANNER-RESMODAL-103: a linked cost saved without a currency is shown in the trip currency, not the display one (#2525)', () => {
// A booking's own cost entry is written without a currency, which means the
// trip's. Reading in USD on a EUR trip must not turn the 120 EUR into $120.00.
budgetEnabled();
seedStore(useSettingsStore, { settings: { default_currency: 'USD' } });
seedStore(useTripStore, {
trip: buildTrip({ id: 1, currency: 'EUR' }),
budgetItems: [
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
],
});
render(
<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />,
);
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
expect(screen.getByText('120,00 €')).toBeInTheDocument();
expect(screen.queryByText('$120.00')).toBeNull();
});
it('FE-PLANNER-RESMODAL-104: an imported price keeps the currency it was quoted in, in the preview and on save (#2525)', async () => {
// A euro trip, a confirmation priced in dollars. The preview said $801.76 and the
// save sent the amount alone, which the server stored as 801.76 EUR.
budgetEnabled();
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
const onSave = vi.fn().mockResolvedValue({ id: 82 });
const prefill = hotelPrefill({ metadata: { price: 801.76, priceCurrency: 'usd' } });
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
expect(screen.getByText('$801.76')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 801.76, category: 'accommodation', currency: 'USD' });
});
it('FE-PLANNER-RESMODAL-105: a parsed currency that is not a code previews and saves in the trip currency', async () => {
budgetEnabled();
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
const onSave = vi.fn().mockResolvedValue({ id: 83 });
const prefill = hotelPrefill({ metadata: { price: 50, priceCurrency: 'dollars' } });
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
// The server drops such a currency too, so the preview names the one it will store.
expect(screen.getByText('50,00 €')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 50, category: 'accommodation' });
});
// ── File error paths ──────────────────────────────────────────────────────── // ── File error paths ────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => { it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => {
@@ -1566,4 +1640,57 @@ describe('ReservationModal', () => {
expect(times[0].value).toBe('16:00'); expect(times[0].value).toBe('16:00');
expect(times[2].value).toBe('10:30'); expect(times[2].value).toBe('10:30');
}); });
// ── Blur booking codes in the edit form (#2457) ─────────────────────────────
describe('blur booking codes (#2457)', () => {
const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } });
it('FE-PLANNER-RESMODAL-096: the booking code field is blurred while the setting is on and the field is not focused', () => {
blurOn(true);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET');
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-RESMODAL-097: a hotel booking hides its code the same way', () => {
blurOn(true);
const res = buildReservation({ type: 'hotel', title: 'Hotel Adlon', confirmation_number: 'HOTEL-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('HOTEL-SECRET'))).toBe(true);
});
it('FE-PLANNER-RESMODAL-098: focusing the field reveals the code for editing, leaving it hides it again', () => {
blurOn(true);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET') as HTMLInputElement;
expect(isBlurred(code)).toBe(true);
act(() => code.focus());
expect(isBlurred(code)).toBe(false);
act(() => code.blur());
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-RESMODAL-099: with the setting off the code stays plain', () => {
blurOn(false);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-PLAIN' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('PNR-PLAIN'))).toBe(false);
});
it('FE-PLANNER-RESMODAL-100: a blurred code still saves unchanged, and an edit typed into it is kept', async () => {
blurOn(true);
const onSave = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ type: 'restaurant', title: 'Dinner', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET');
await userEvent.clear(code);
await userEvent.type(code, 'PNR-NEW');
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].confirmation_number).toBe('PNR-NEW');
});
});
}); });
@@ -6,6 +6,7 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import { buildAssignmentOptions } from './assignmentOptions' import { buildAssignmentOptions } from './assignmentOptions'
import AddressInput from './AddressInput' import AddressInput from './AddressInput'
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react' import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react'
@@ -18,11 +19,13 @@ import { parseReservationMetadata } from '../../utils/flightLegs'
import { resolveDayId } from '../../utils/formatters' import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types' import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection' import { BookingCostsSection } from './BookingCostsSection'
import { importedPriceEntry } from './importedPrice'
import { TravelerPicker } from './TravelerPicker' import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips' import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft' import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared' import { typeToCostCategory } from '@trek/shared'
import { stayPlaces } from '../../utils/stayPlaces'
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel }, { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
@@ -304,11 +307,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
// Imported booking → auto-create the linked cost from the parsed price (what the // Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price. // old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) { if (!reservation && prefill && isBudgetEnabled) {
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {} const entry = importedPriceEntry(prefill.metadata, form.type)
const price = Number(pmeta.price) if (entry) saveData.create_budget_entry = entry
if (Number.isFinite(price) && price > 0) {
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
}
} }
const saved = await onSave(saveData) const saved = await onSave(saveData)
// Persist the traveler assignment once we have the reservation id (create → save // Persist the traveler assignment once we have the reservation id (create → save
@@ -350,12 +350,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
} }
// On an import review (not yet saved), preview the parsed price as the cost that will be linked. // On an import review (not yet saved), preview the parsed price as the cost that will be
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null // linked: the same entry the save sends, so the two cannot name different currencies.
const prefillPrice = Number(prefillMeta?.price) const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
: null
const handleFileChange = async (e) => { const handleFileChange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0] const file = (e.target as HTMLInputElement).files?.[0]
@@ -559,7 +557,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label> <label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)} <BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} /> placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div> </div>
<div> <div>
@@ -600,7 +598,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.meta.pickHotel')} placeholder={t('reservations.meta.pickHotel')}
options={[ options={[
{ value: '', label: '—' }, { value: '', label: '—' },
...places.map(p => ({ value: p.id, label: p.name })), ...stayPlaces(places, form.hotel_place_id).map(p => ({ value: p.id, label: p.name })),
]} ]}
searchable searchable
size="sm" size="sm"
@@ -1,11 +1,13 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-064 // FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-070
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import { render, screen, waitFor, fireEvent, within, act } 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 { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore'; import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { import {
buildUser, buildUser,
@@ -39,8 +41,13 @@ vi.mock('./AirportSelect', () => ({
})); }));
vi.mock('./LocationSelect', () => ({ vi.mock('./LocationSelect', () => ({
default: ({ onChange }: { onChange: (l: any) => void }) => ( default: ({ onChange, places }: { onChange: (l: any) => void; places?: { name: string }[] }) => (
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} /> <input
data-testid="location-select"
data-picks={(places ?? []).map(p => p.name).join('|')}
type="text"
onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })}
/>
), ),
})); }));
@@ -88,6 +95,36 @@ describe('TransportModal', () => {
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument(); expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
}); });
it('FE-PLANNER-TRANSMODAL-003b: shows a delete action when editing with onDelete', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} onDelete={vi.fn()} />);
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-003c: no delete action when creating, or when onDelete is omitted', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
const { rerender } = render(<TransportModal {...defaultProps} reservation={null} onDelete={vi.fn()} />);
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
rerender(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-003d: confirming delete calls onDelete then onClose', async () => {
const onDelete = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} onDelete={onDelete} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /delete/i }));
expect(screen.getByText('Delete booking?')).toBeInTheDocument();
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => { it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />); render(<TransportModal {...defaultProps} onSave={onSave} />);
@@ -1083,7 +1120,9 @@ describe('TransportModal', () => {
await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights' }); // The parsed currency travels with the price (#2525), or the server would store
// it in whatever the trip currency is.
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights', currency: 'EUR' });
}); });
// ── File edge cases ───────────────────────────────────────────────────────── // ── File edge cases ─────────────────────────────────────────────────────────
@@ -1357,4 +1396,108 @@ describe('TransportModal', () => {
expect(screen.getByText(/Orsay/)).toBeInTheDocument(); expect(screen.getByText(/Orsay/)).toBeInTheDocument();
expect(screen.queryByText('Louvre')).not.toBeInTheDocument(); expect(screen.queryByText('Louvre')).not.toBeInTheDocument();
}); });
// ── Blur booking codes in the edit form (#2457) ─────────────────────────────
describe('blur booking codes (#2457)', () => {
const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } });
const codeFields = () => screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
function multiLegTrain(): Reservation {
const res = buildReservation({ id: 32, title: 'Osaka → Kyoto → Nara', type: 'train' });
return Object.assign(res, {
day_id: 10,
end_day_id: 10,
confirmation_number: 'RAIL-BOOK',
metadata: {
legs: [
{ from: 'Osaka', to: 'Kyoto', train_number: 'JR 1', confirmation_number: 'RAIL-LEG1', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '08:30' },
{ from: 'Kyoto', to: 'Nara', train_number: 'JR 2', confirmation_number: 'RAIL-LEG2', dep_day_id: 10, dep_time: '09:00', arr_day_id: 10, arr_time: '09:45' },
],
},
endpoints: [
{ id: 11, reservation_id: 32, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '08:00' },
{ id: 12, reservation_id: 32, role: 'stop', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:00' },
{ id: 13, reservation_id: 32, role: 'to', sequence: 2, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:45' },
],
}) as unknown as Reservation;
}
it('FE-PLANNER-TRANSMODAL-065: the flight booking code field is blurred while the setting is on', () => {
blurOn(true);
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('FLY-SECRET'))).toBe(true);
});
it('FE-PLANNER-TRANSMODAL-066: every per-segment code of a stopover flight is blurred as well', () => {
blurOn(true);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
const codes = codeFields();
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]);
});
it('FE-PLANNER-TRANSMODAL-067: every per-segment code of a multi-leg train is blurred as well', () => {
blurOn(true);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} />);
const codes = codeFields();
expect(codes.map(i => i.value)).toEqual(['RAIL-LEG1', 'RAIL-LEG2', 'RAIL-BOOK']);
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]);
});
it('FE-PLANNER-TRANSMODAL-068: focusing a code field reveals it for editing, leaving it hides it again', () => {
blurOn(true);
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
render(<TransportModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('FLY-SECRET') as HTMLInputElement;
expect(isBlurred(code)).toBe(true);
act(() => code.focus());
expect(isBlurred(code)).toBe(false);
act(() => code.blur());
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-TRANSMODAL-069: with the setting off every code field stays plain', () => {
blurOn(false);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]);
});
it('FE-PLANNER-TRANSMODAL-070: blurred codes still save unchanged', async () => {
blurOn(true);
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
expect(payload.confirmation_number).toBe('BOOK1');
});
});
it('FE-PLANNER-TRANSMODAL-071: the manual From and To fields offer the trip places that have a location, each once (#2468)', async () => {
const places = [
buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.33 }),
buildPlace({ id: 2, name: 'No pin yet', lat: null, lng: null }),
buildPlace({ id: 3, name: 'Louvre', lat: 48.86, lng: 2.33 }),
];
render(<TransportModal {...defaultProps} places={places} />);
await userEvent.click(screen.getByRole('button', { name: /^Bus$/i }));
const fields = screen.getAllByTestId('location-select');
expect(fields).toHaveLength(2);
for (const field of fields) expect(field).toHaveAttribute('data-picks', 'Louvre');
});
it('FE-PLANNER-TRANSMODAL-072: every train station field offers the trip places too', async () => {
const places = [buildPlace({ id: 1, name: 'Berlin Hbf', lat: 52.52, lng: 13.37 })];
render(<TransportModal {...defaultProps} places={places} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const stations = screen.getAllByTestId('location-select');
expect(stations).toHaveLength(3);
for (const station of stations) expect(station).toHaveAttribute('data-picks', 'Berlin Hbf');
});
}); });
@@ -1,11 +1,14 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router' import { useParams } from 'react-router'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react' import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import AirportSelect, { type Airport } from './AirportSelect' import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect' import LocationSelect, { type LocationPoint } from './LocationSelect'
import { toLocationPicks } from './locationPicks'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
@@ -14,8 +17,9 @@ import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types' import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs' import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection' import { BookingCostsSection } from './BookingCostsSection'
import { importedPriceEntry } from './importedPrice'
import { TravelerPicker } from './TravelerPicker' import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips' import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -55,14 +59,6 @@ function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', se
} }
} }
// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle". Done as a trim
// plus an anchored test instead of /\s*\([A-Z]{3}\)\s*$/, because the leading
// \s* backtracks over every space in a long name for a quadratic worst case.
function stripAirportCode(name: string): string {
const trimmed = name.trimEnd()
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null { function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null if (!e || !e.code) return null
return { return {
@@ -172,6 +168,8 @@ interface TransportModalProps {
files?: TripFile[] files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<unknown> onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void> onFileDelete?: (fileId: number) => Promise<void>
/** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */
onDelete?: () => void | Promise<void>
onOpenExpense?: (req: BookingExpenseRequest) => void onOpenExpense?: (req: BookingExpenseRequest) => void
// Pre-fill a brand-new transport booking from a parsed import item (review- // Pre-fill a brand-new transport booking from a parsed import item (review-
// before-save); like `reservation` for the form but stays in create mode. // before-save); like `reservation` for the form but stays in create mode.
@@ -191,9 +189,11 @@ interface TransportModalProps {
tripMembers?: TripMember[] tripMembers?: TripMember[]
} }
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) { export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
// The trip's places, offered by every location field of the manual tab (#2468).
const locationPicks = useMemo(() => toLocationPicks(places), [places])
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems) const budgetItems = useTripStore(s => s.budgetItems)
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem) const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
@@ -207,6 +207,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// Manual vs Automated (public transit search) creation mode (#1065). // Manual vs Automated (public transit search) creation mode (#1065).
const [automated, setAutomated] = useState(false) const [automated, setAutomated] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({}) const [toPick, setToPick] = useState<EndpointPick>({})
// Flight route as an ordered list of airports (origin .. stops .. destination). // Flight route as an ordered list of airports (origin .. stops .. destination).
@@ -616,11 +617,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// Imported booking → auto-create the linked cost from the parsed price (what the // Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price. // old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) { if (!reservation && prefill && isBudgetEnabled) {
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {} const entry = importedPriceEntry(prefill.metadata, form.type)
const price = Number(pmeta.price) if (entry) (payload as Record<string, unknown>).create_budget_entry = entry
if (Number.isFinite(price) && price > 0) {
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
}
} }
const saved = await onSave(payload) const saved = await onSave(payload)
// Persist the traveler assignment once we have the reservation id (create → save // Persist the traveler assignment once we have the reservation id (create → save
@@ -664,12 +662,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
} }
// On an import review (not yet saved), preview the parsed price as the cost that will be linked. // On an import review (not yet saved), preview the parsed price as the cost that will be
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null // linked: the same entry the save sends, so the two cannot name different currencies.
const prefillPrice = Number(prefillMeta?.price) const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
: null
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@@ -732,15 +728,24 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')} title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl" size="2xl"
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}>
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}> <div>
{t('common.cancel')} {!automated && reservation?.id && onDelete && (
</button> <button type="button" onClick={() => setShowDeleteConfirm(true)} className="text-danger" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '8px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{!automated && ( <Trash2 size={13} /> {t('common.delete')}
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}> </button>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')} )}
</button> </div>
)} <div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')}
</button>
{!automated && (
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
)}
</div>
</div> </div>
} }
> >
@@ -903,7 +908,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{writesFlightLegs && ( {writesFlightLegs && (
<div> <div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label> <label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })} <BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} /> placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div> </div>
)} )}
@@ -936,7 +941,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span> <span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} /> <LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} places={locationPicks} />
</div> </div>
{!isFirst && !isLast && ( {!isFirst && !isLast && (
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}> <button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
@@ -984,7 +989,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{writesTrainLegs && ( {writesTrainLegs && (
<div> <div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label> <label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })} <BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} /> placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div> </div>
)} )}
@@ -1008,11 +1013,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className={labelClass}>{t('reservations.meta.from')}</label> <label className={labelClass}>{t('reservations.meta.from')}</label>
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} /> <LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} places={locationPicks} />
</div> </div>
<div> <div>
<label className={labelClass}>{t('reservations.meta.to')}</label> <label className={labelClass}>{t('reservations.meta.to')}</label>
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} /> <LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} places={locationPicks} />
</div> </div>
</div> </div>
@@ -1056,6 +1061,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<LocationSelect <LocationSelect
value={stop.location} value={stop.location}
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))} onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
places={locationPicks}
/> />
</div> </div>
<div style={{ width: 110, flexShrink: 0 }}> <div style={{ width: 110, flexShrink: 0 }}>
@@ -1119,7 +1125,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label> <label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)} <BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} /> placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div> </div>
<div> <div>
@@ -1252,6 +1258,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</form> </form>
)} )}
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('reservations.confirm.deleteTitle')}
message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
onConfirm={async () => {
setShowDeleteConfirm(false)
await onDelete?.()
onClose()
}}
/>
</Modal> </Modal>
) )
} }
@@ -96,7 +96,8 @@ describe('TripExportModal', () => {
await user.click(screen.getByText('dayplan.pdf')) await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1)) await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })], trip, days, showServiceStops: true,
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
}) })
}) })
@@ -6,6 +6,7 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
/** /**
* What a GPX download can carry. Worded by what someone wants on their device * What a GPX download can carry. Worded by what someone wants on their device
@@ -61,6 +62,9 @@ export function TripExportModal({
// The PDF is built outside React, so it cannot read this itself (#2066). // The PDF is built outside React, so it cannot read this itself (#2066).
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
// The export gets the store's assignments and applies the day plan's own filter to
// them, so it needs the same switch the plan reads.
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId)
const fileBase = trip?.title || 'trip' const fileBase = trip?.title || 'trip'
// Shared tail of every download: Firefox and Safari cancel the download when // Shared tail of every download: Firefox and Safari cancel the download when
@@ -87,7 +91,7 @@ export function TripExportModal({
// exported. A missing chunk lands in the catch and shows the same error // exported. A missing chunk lands in the catch and shows the same error
// the export already had. // the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF') const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit }) await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops })
onClose() onClose()
} catch (e) { } catch (e) {
console.error('PDF error:', e) console.error('PDF error:', e)
@@ -0,0 +1,25 @@
import { typeToCostCategory } from '@trek/shared'
/**
* The linked cost an imported booking's parsed price becomes, or null when it has none.
*
* The price keeps the currency it was quoted in (#2525). The forms used to send the
* amount alone, so an imported $801.76 was stored as 801.76 in the trip's own currency
* while the form had just previewed it in dollars. A currency that is not a three-letter
* code is left out, which is what the server does with one too, so the preview and the
* saved cost read the same either way.
*/
export function importedPriceEntry(
metadata: unknown,
type: string,
): { total_price: number; category: string; currency?: string } | null {
const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
const price = Number(meta.price)
if (!Number.isFinite(price) || price <= 0) return null
const code = typeof meta.priceCurrency === 'string' ? meta.priceCurrency.trim().toUpperCase() : ''
return {
total_price: price,
category: typeToCostCategory(type),
...(/^[A-Z]{3}$/.test(code) ? { currency: code } : {}),
}
}
@@ -0,0 +1,32 @@
// FE-PLANNER-LOCPICKS-001 to FE-PLANNER-LOCPICKS-003
import { toLocationPicks } from './locationPicks';
describe('toLocationPicks', () => {
it('FE-PLANNER-LOCPICKS-001: a trip place becomes a pick with its name, coordinates and address', () => {
expect(toLocationPicks([{ name: ' Bach Suites Saigon ', lat: '10.78', lng: 106.7, address: 'District 1' }])).toEqual([
{ name: 'Bach Suites Saigon', lat: 10.78, lng: 106.7, address: 'District 1' },
]);
});
it('FE-PLANNER-LOCPICKS-002: a place without a name or a real coordinate is not offered, never as 0,0', () => {
expect(toLocationPicks([
{ name: 'No coordinates', lat: null, lng: null },
{ name: 'Half a coordinate', lat: 10, lng: null },
{ name: 'Blank coordinate', lat: '', lng: '' },
{ name: 'Not a number', lat: 'north', lng: 106.7 },
{ name: ' ', lat: 10, lng: 106 },
{ name: null, lat: 10, lng: 106 },
])).toEqual([]);
expect(toLocationPicks(undefined)).toEqual([]);
});
it('FE-PLANNER-LOCPICKS-003: the same place added twice is offered once, a namesake elsewhere stays', () => {
const picks = toLocationPicks([
{ name: 'Hotel', lat: 10, lng: 106 },
{ name: 'Hotel', lat: 10, lng: 106 },
{ name: 'Hotel', lat: 11, lng: 106 },
]);
expect(picks.map(p => `${p.name}@${p.lat}`)).toEqual(['Hotel@10', 'Hotel@11']);
expect(picks[0].address).toBeNull();
});
});
@@ -0,0 +1,33 @@
import type { LocationPoint } from './LocationSelect'
/** A trip place as far as a location field can offer it. */
interface PlaceLike {
name?: string | null
lat?: number | string | null
lng?: number | string | null
address?: string | null
}
/**
* The trip's places as ready-made picks for a location field.
*
* Only a place with a name and a real coordinate is offered. A place without one would
* be picked as 0,0 (`Number(null)` is a finite 0), which puts the endpoint of a booking
* in the Gulf of Guinea. The same place added twice is offered once.
*/
export function toLocationPicks(places: readonly PlaceLike[] | null | undefined): LocationPoint[] {
const picks: LocationPoint[] = []
const seen = new Set<string>()
for (const place of places ?? []) {
const name = place.name?.trim()
if (!name || place.lat == null || place.lng == null || place.lat === '' || place.lng === '') continue
const lat = Number(place.lat)
const lng = Number(place.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
const key = `${name}:${lat}:${lng}`
if (seen.has(key)) continue
seen.add(key)
picks.push({ name, lat, lng, address: place.address ?? null })
}
return picks
}
@@ -0,0 +1,70 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { Footprints } from 'lucide-react'
import FigureBadge from './FigureBadge'
// FE-FIGURE-BADGE-001 to FE-FIGURE-BADGE-005
/** The classes a cell carries, as a set, so the order they are written in does not matter. */
const classes = (el: Element | null): string[] => (el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).sort()
const of = (list: string): string[] => list.split(' ').sort()
describe('FigureBadge', () => {
it('FE-FIGURE-BADGE-001: the neutral shell is the one the stay, the walk and the fill-up wore before', () => {
const { container } = render(<FigureBadge lead={<Footprints size={9} aria-hidden />} value="240 m" />)
const shell = container.firstElementChild!
expect(classes(shell)).toEqual(of('inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'))
expect(classes(shell.children[0])).toEqual(of('flex items-center gap-0.5 px-1 bg-surface-tertiary text-content-faint'))
expect(classes(shell.children[1])).toEqual(
of('flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums border-edge text-content-secondary'),
)
expect(shell.children[1]).toHaveTextContent('240 m')
})
it('FE-FIGURE-BADGE-002: the warning tone edges both halves in the warning colour itself', () => {
const { container } = render(<FigureBadge tone="warning" dir="ltr" lead="!" value="+30 min" />)
const shell = container.firstElementChild!
expect(shell).toHaveAttribute('dir', 'ltr')
expect(classes(shell)).toContain('border-warning')
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['bg-warning-soft', 'text-warning']))
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['border-warning', 'text-warning']))
})
it('FE-FIGURE-BADGE-003: a word in the lead is a caption, a figure can be quiet, and without one the sign stands alone', () => {
const caption = render(<FigureBadge caption lead="Stay" value="+" faint valueSize="micro" />)
const shell = caption.container.firstElementChild!
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['font-geist', 'font-semibold', 'uppercase', 'tracking-[0.12em]']))
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['text-content-faint']))
expect((shell.children[1] as HTMLElement).style.fontSize).toContain('8px')
caption.unmount()
const sign = render(<FigureBadge lead={<Footprints size={9} aria-label="Walk" />} />)
expect(sign.container.firstElementChild!.children).toHaveLength(1)
expect(screen.getByLabelText('Walk')).toBeInTheDocument()
})
it('FE-FIGURE-BADGE-004: as a control it is a focusable button role that answers the keys meaning press and keeps them from the row', () => {
const onActivate = vi.fn()
const onRow = vi.fn()
render(
<div role="presentation" onClick={onRow} onKeyDown={onRow}>
<FigureBadge lead="Stay" value="1 h" onActivate={onActivate} ariaLabel="Stay: 1 h" />
</div>,
)
const badge = screen.getByRole('button', { name: 'Stay: 1 h' })
fireEvent.click(badge)
fireEvent.keyDown(badge, { key: 'Enter' })
fireEvent.keyDown(badge, { key: ' ' })
fireEvent.keyDown(badge, { key: 'Tab' })
expect(onActivate).toHaveBeenCalledTimes(3)
expect(onActivate).toHaveBeenCalledWith(badge)
// Only the Tab went on to the row.
expect(onRow).toHaveBeenCalledTimes(1)
})
it('FE-FIGURE-BADGE-005: without a handler it is no control at all', () => {
render(<FigureBadge lead="Stay" value="1 h" tooltip="How long you stay" />)
expect(screen.queryByRole('button')).toBeNull()
})
})
@@ -0,0 +1,95 @@
import React from 'react'
import { Tooltip } from '../shared/Tooltip'
import { FS } from './typeScale'
/**
* A figure in the two-part shell the rail uses everywhere: what it is on the left, the
* number on the right, one hairline between them.
*
* One component because the shell had been typed out six times, the stay, the walk, the
* fill-up, the two findings and the limits button each carrying their own copy, and the
* copies had started to drift. The warning tone keeps the border in the warning colour
* itself, not its soft tint: at this size a tinted edge disappears into the card.
*/
const TONES = {
neutral: {
shell: 'border-edge',
lead: 'bg-surface-tertiary text-content-faint',
value: 'border-edge text-content-secondary',
},
warning: {
shell: 'border-warning',
lead: 'bg-warning-soft text-warning',
value: 'border-warning text-warning',
},
} as const
interface FigureBadgeProps {
/** An icon, or a word set as a caption (`caption`). */
lead: React.ReactNode
/** The figure. Absent draws the lead alone, a badge that is only a sign. */
value?: React.ReactNode
tone?: 'neutral' | 'warning'
tooltip?: string
/** The lead is a word, set small, wide and in capitals like the stay's. */
caption?: boolean
/** The figure in the quiet ink: an invitation, or a value inherited rather than set. */
faint?: boolean
valueSize?: 'label' | 'micro'
dir?: 'ltr'
/**
* Makes the badge the control that changes its figure. A span with the button role, not
* a <button>: the stop row around it is already one, and a button inside a button is
* invalid HTML that browsers resolve by dropping the inner element. The press stops at
* the badge, so the row does not select its stop and move the map from under the panel.
*/
onActivate?: (anchor: HTMLElement) => void
/** What a screen reader hears for the control, when the badge is one. */
ariaLabel?: string
}
export default function FigureBadge({
lead, value, tone = 'neutral', tooltip, caption, faint, valueSize = 'label', dir, onActivate, ariaLabel,
}: FigureBadgeProps): React.ReactElement {
const colours = TONES[tone]
const shell = `inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border ${colours.shell}`
const body = (
<>
<span
className={`flex items-center gap-0.5 px-1 ${colours.lead} ${caption ? 'font-geist font-semibold uppercase tracking-[0.12em]' : ''}`}
style={{ fontSize: FS.micro }}
>
{lead}
</span>
{value === undefined ? null : (
<span
className={`flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums ${faint ? 'border-edge text-content-faint' : colours.value}`}
style={{ fontSize: FS[valueSize] }}
>
{value}
</span>
)}
</>
)
const badge = onActivate ? (
<span
role="button"
tabIndex={0}
dir={dir}
aria-label={ariaLabel}
onClick={e => { e.stopPropagation(); onActivate(e.currentTarget) }}
onKeyDown={e => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
onActivate(e.currentTarget)
}}
className={`${shell} cursor-pointer transition-colors hover:border-content-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
>
{body}
</span>
) : (
<span dir={dir} className={shell}>{body}</span>
)
return tooltip ? <Tooltip label={tooltip}>{badge}</Tooltip> : badge
}
@@ -5,9 +5,10 @@ import { useSettingsStore } from '../../store/settingsStore'
import RoadtripAlternativesBar from './RoadtripAlternativesBar' import RoadtripAlternativesBar from './RoadtripAlternativesBar'
import type { AlternativeOverlay } from './alternativeOverlays' import type { AlternativeOverlay } from './alternativeOverlays'
import type { LegAlternatives } from './useRouteAlternatives' import type { LegAlternatives } from './useRouteAlternatives'
import { openLeg } from '../../../tests/helpers/legAlternatives'
/** /**
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map. * FE-ALTBAR-001..014: the ways of driving one leg, offered over the map.
* *
* Every value shown comes from the same overlay the map draws. Working them out * Every value shown comes from the same overlay the map draws. Working them out
* here separately is what once made the list label the driven route "Fastest" * here separately is what once made the list label the driven route "Fastest"
@@ -26,14 +27,13 @@ const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay =>
distance: 290_000, distance: 290_000,
slowerThanQuickest: 0, slowerThanQuickest: 0,
otherEngine: false, otherEngine: false,
engine: 'osrm',
labelBg: '#0a84ff', labelBg: '#0a84ff',
at: { lat: 53, lng: 11 }, at: { lat: 53, lng: 11 },
...over, ...over,
}) })
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({ const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => openLeg({ dayId: 4, drive: { kind: 'leg', index: 1 }, ...over })
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
})
beforeEach(() => { beforeEach(() => {
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never }) useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
@@ -144,18 +144,88 @@ describe('RoadtripAlternativesBar', () => {
expect(onClose).toHaveBeenCalled() expect(onClose).toHaveBeenCalled()
}) })
it('FE-ALTBAR-010: an offer the other engine timed says so, and the rest do not', () => { it('FE-ALTBAR-010: an offer the other engine timed says so, naming that engine, and the rest do not', () => {
render( render(
<RoadtripAlternativesBar <RoadtripAlternativesBar
open={leg()} open={leg()}
overlays={[ overlays={[
overlay({ note: 'Fastest' }), overlay({ note: 'Fastest' }),
overlay({ index: 1, note: 'No tolls', otherEngine: true }), overlay({ index: 1, note: 'No tolls', otherEngine: true, engine: 'valhalla' }),
]} ]}
onChoose={vi.fn()} onChoose={vi.fn()}
onClose={vi.fn()} onClose={vi.fn()}
/>, />,
) )
expect(screen.getAllByLabelText(/avoidance router/i)).toHaveLength(1) expect(screen.getAllByLabelText(/avoidance router/i)).toHaveLength(1)
expect(screen.queryByLabelText(/main router/i)).toBeNull()
})
it('FE-ALTBAR-011: while a choice is checked and saved the list stands still, and the one in hand says so', () => {
// A second click would race the first, and the router's answer to it could land on
// the leg after it.
const onChoose = vi.fn()
render(
<RoadtripAlternativesBar
open={leg({ proving: 1 })}
overlays={[overlay({ index: 0, note: 'Current' }), overlay({ index: 1 }), overlay({ index: 2 })]}
onChoose={onChoose}
onClose={vi.fn()}
/>,
)
const [, current, checked, other] = screen.getAllByRole('button')
expect([current, checked, other].every(chip => chip.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(checked).toHaveAttribute('aria-busy', 'true')
expect(other).toHaveAttribute('aria-busy', 'false')
expect(checked.parentElement).toHaveAttribute('aria-busy', 'true')
fireEvent.click(other)
expect(onChoose).not.toHaveBeenCalled()
// Closing stays available: it is how a check nobody wants to wait for is abandoned.
expect(screen.getByRole('button', { name: /close/i })).not.toBeDisabled()
})
it('FE-ALTBAR-012: with nothing being checked every offer can be taken', () => {
render(
<RoadtripAlternativesBar open={leg()} overlays={[overlay({ index: 0 }), overlay({ index: 1 })]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
const chips = screen.getAllByRole('button').slice(1)
expect(chips.every(chip => chip.getAttribute('aria-disabled') === 'false')).toBe(true)
expect(chips[0].parentElement).toHaveAttribute('aria-busy', 'false')
})
it('FE-ALTBAR-013: a keyboard keeps its place on the chip it chose while the check runs', () => {
// A disabled button hands its focus to the page, so Enter on a chip left the keyboard
// nowhere in the bar that was still open for another choice.
const onChoose = vi.fn()
const overlays = [overlay({ index: 0, note: 'Current' }), overlay({ index: 1 })]
const { rerender } = render(
<RoadtripAlternativesBar open={leg()} overlays={overlays} onChoose={onChoose} onClose={vi.fn()} />,
)
const chip = screen.getAllByRole('button')[2]
chip.focus()
fireEvent.click(chip)
expect(onChoose).toHaveBeenCalledWith(1)
rerender(<RoadtripAlternativesBar open={leg({ proving: 1 })} overlays={overlays} onChoose={onChoose} onClose={vi.fn()} />)
expect(document.activeElement).toBe(chip)
rerender(<RoadtripAlternativesBar open={leg({ notice: 'Not saved.' })} overlays={overlays} onChoose={onChoose} onClose={vi.fn()} />)
expect(document.activeElement).toBe(chip)
})
it('FE-ALTBAR-014: the bar says a check is running and why the last one saved nothing, in a live line', () => {
const overlays = [overlay({ index: 0, note: 'Current' }), overlay({ index: 1 })]
const { rerender } = render(
<RoadtripAlternativesBar open={leg()} overlays={overlays} onChoose={vi.fn()} onClose={vi.fn()} />,
)
// There before it has anything to say, so a screen reader is listening when it does.
const status = screen.getByRole('status')
expect(status).toBeEmptyDOMElement()
expect(status).toHaveClass('sr-only')
rerender(<RoadtripAlternativesBar open={leg({ proving: 1 })} overlays={overlays} onChoose={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByRole('status')).toHaveTextContent(/checking this way/i)
const notice = 'The road trip’s router won’t follow this way, so it was not saved.'
rerender(<RoadtripAlternativesBar open={leg({ notice })} overlays={overlays} onChoose={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByRole('status')).toHaveTextContent(notice)
expect(screen.getByRole('status')).not.toHaveClass('sr-only')
}) })
}) })
@@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import { Shuffle, X, AlertTriangle, Info } from 'lucide-react' import { Shuffle, X, AlertTriangle, Info, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext' import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units' import { formatDistance } from '../../utils/units'
import type { LegAlternatives } from './useRouteAlternatives' import type { LegAlternatives } from './useRouteAlternatives'
import { alternativesPhase, alternativeSubline, type AlternativeOverlay } from './alternativeOverlays' import { alternativesBusy, alternativesPhase, alternativeSubline, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
interface RoadtripAlternativesBarProps { interface RoadtripAlternativesBarProps {
open: LegAlternatives | null open: LegAlternatives | null
@@ -35,6 +35,13 @@ export default function RoadtripAlternativesBar({
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
if (!open) return null if (!open) return null
const phase = alternativesPhase(open, overlays) const phase = alternativesPhase(open, overlays)
// While a choice is checked against the router and written, the list stands still: a
// second click would race the first, and its answer could land on the leg after it.
const busy = alternativesBusy(open)
// What the check is doing, and why the last one saved nothing. A toast says the latter
// too, but it is announced to nobody and gone in seconds, while this line sits beside
// the chips it is about.
const status = busy ? t('roadtrip.alt.checking') : (open.notice ?? '')
return ( return (
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur"> <div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
@@ -64,48 +71,61 @@ export default function RoadtripAlternativesBar({
// One route back means there is genuinely only one sensible way to drive it. // One route back means there is genuinely only one sensible way to drive it.
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p> <p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
) : ( ) : (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5" aria-busy={busy}>
{overlays.map(alt => ( {overlays.map(alt => {
<button const note = otherEngineNote(alt)
key={alt.index} return (
type="button" <button
onClick={() => onChoose(alt.index)} key={alt.index}
onMouseEnter={() => onHighlight?.(alt.index)} type="button"
onMouseLeave={() => onHighlight?.(null)} // Refused rather than disabled: a disabled button gives its focus up, and a
onFocus={() => onHighlight?.(alt.index)} // keyboard that chose it would land on the page with the check still running.
onBlur={() => onHighlight?.(null)} aria-disabled={busy}
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint" aria-busy={open.proving === alt.index}
> onClick={() => { if (!busy) onChoose(alt.index) }}
<span onMouseEnter={() => onHighlight?.(alt.index)}
className="h-2.5 w-2.5 shrink-0 rounded-full" onMouseLeave={() => onHighlight?.(null)}
// theme-lint-disable — the very colour this route is drawn in on the map; onFocus={() => onHighlight?.(alt.index)}
// a token here would break the one link between list and picture. onBlur={() => onHighlight?.(null)}
style={{ background: alt.color }} className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint aria-disabled:cursor-default aria-disabled:opacity-60 aria-disabled:hover:border-edge"
aria-hidden >
/> {open.proving === alt.index ? (
<span className="flex flex-col"> <Loader2 size={10} className="shrink-0 animate-spin text-content-faint" aria-hidden />
<span className="text-caption font-medium tabular-nums text-content"> ) : (
{formatDistance(alt.distance / 1000, distanceUnit)} <span
className="h-2.5 w-2.5 shrink-0 rounded-full"
// theme-lint-disable: the very colour this route is drawn in on the map;
// a token here would break the one link between list and picture.
style={{ background: alt.color }}
aria-hidden
/>
)}
<span className="flex flex-col">
<span className="text-caption font-medium tabular-nums text-content">
{formatDistance(alt.distance / 1000, distanceUnit)}
</span>
<span className="text-caption tabular-nums text-content-muted">
{alternativeSubline(alt, time => t('roadtrip.alt.slower', { time }))}
</span>
</span> </span>
<span className="text-caption tabular-nums text-content-muted"> {/* Only where the drive time on the map came from the other engine. The
{alternativeSubline(alt, time => t('roadtrip.alt.slower', { time }))} road and the length are the same question either way; the time is not,
</span> and two engines' times sitting next to each other on the map invite a
</span> subtraction that means nothing. Saying so here, where the choice is
{/* Only where the drive time on the map came from the other engine. The actually made, is cheaper than a second request per offer to restate
road and the length are the same question either way; the time is not, the whole list in one engine's terms. */}
and two engines' times sitting next to each other on the map invite a {note ? (
subtraction that means nothing. Saying so here, where the choice is <Tooltip label={t(note)}>
actually made, is cheaper than a second request per offer to restate <Info size={12} className="shrink-0 text-content-faint" aria-label={t(note)} />
the whole list in one engine's terms. */} </Tooltip>
{alt.otherEngine ? ( ) : null}
<Tooltip label={t('roadtrip.alt.otherEngine')}> </button>
<Info size={12} className="shrink-0 text-content-faint" aria-label={t('roadtrip.alt.otherEngine')} /> )
</Tooltip> })}
) : null}
</button>
))}
</div> </div>
)} )}
{/* Kept in the tree while empty, so a screen reader is listening before it speaks. */}
<p role="status" className={status ? 'text-caption text-content-muted' : 'sr-only'}>{status}</p>
</div> </div>
) )
} }
@@ -5,12 +5,12 @@ import { fireEvent, render, screen, within } from '../../../tests/helpers/render
import RoadtripCategoryPicker from './RoadtripCategoryPicker' import RoadtripCategoryPicker from './RoadtripCategoryPicker'
/** /**
* FE-CATPICK-001..008 — what the corridor is asked to look for. * FE-CATPICK-001..009 — what the corridor is asked to look for.
* *
* Multi-select, so it stays open on a click: picking "fuel and charging" is one * Multi-select, so it stays open on a click: picking "fuel and charging" is one
* gesture rather than two round trips through a menu. The summary names the * gesture rather than two round trips through a menu. Closed, it draws the picked
* kinds rather than counting them, because "Fuel, Charging" is the answer and * kinds as icons only and names them to a screen reader and in its tooltip,
* "2 kinds" is a riddle. * because "Fuel, Charging" is the answer and "2 kinds" is a riddle.
*/ */
const KEYS = ['fuel', 'charging', 'food'] as const const KEYS = ['fuel', 'charging', 'food'] as const
@@ -40,11 +40,18 @@ describe('RoadtripCategoryPicker', () => {
expect(screen.getByText('Looking for')).toBeInTheDocument() expect(screen.getByText('Looking for')).toBeInTheDocument()
}) })
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => { it('FE-CATPICK-003: closed, it names the kinds in the order they are offered, without printing them', () => {
// Reversed on the way in: the control lists them the way it offers them, not // Reversed on the way in: the control lists them the way it offers them, not
// the way they happened to be clicked. // the way they happened to be clicked.
picker(['food', 'fuel']) picker(['food', 'fuel'])
expect(screen.getByText('Fuel, Food')).toBeInTheDocument() expect(trigger()).toHaveAccessibleName('Looking for: Fuel, Food')
expect(screen.queryByText('Fuel, Food')).toBeNull()
expect(screen.queryByText('Fuel')).toBeNull()
})
it('FE-CATPICK-009: closed, every picked kind shows as its icon, however many there are', () => {
picker(['food', 'fuel', 'charging'])
expect(trigger().querySelectorAll('span.rounded-md svg')).toHaveLength(3)
}) })
it('FE-CATPICK-004: opening lists every kind', () => { it('FE-CATPICK-004: opening lists every kind', () => {
@@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from 'react' import React from 'react'
import { Check, ChevronDown, type LucideIcon } from 'lucide-react' import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext' import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { DROPDOWN_LIST, DROPDOWN_ROW, DROPDOWN_TRIGGER, useDropdown } from './railDropdown'
import { serviceColor } from './roadtripModel' import { serviceColor } from './roadtripModel'
import { FS } from './typeScale' import { FS } from './typeScale'
@@ -13,82 +15,68 @@ interface RoadtripCategoryPickerProps {
} }
/** /**
* What to look for, as one full-width control. * What to look for, as one line.
* *
* Six pills wrapped onto three lines and took the height of the whole card for a set of * Six pills wrapped onto three lines and took the height of the whole card for a set of
* answers that rarely changes. Folded into a dropdown it is one line, and the space goes * answers that rarely changes. Folded into a dropdown it is one line, and the space goes
* to the results — which is what the column is actually for. * to the results, which is what the column is actually for.
*
* Closed, it shows the picked kinds by their coloured icons alone, the same ones the rail
* puts on its dashed line and the map puts on the route; the names are in the list and in
* the tooltip. That leaves the line room for the day picker beside it.
* *
* Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture * Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture
* rather than two round trips through a menu. Each row leads with its own coloured icon, * rather than two round trips through a menu.
* the same one the rail puts on its dashed line and the map puts on the route.
*/ */
export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement { export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const { open, setOpen, ref } = useDropdown<HTMLDivElement>()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointer = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open])
const picked = keys.filter(k => selected.includes(k))
// The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds" // The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds"
// is a riddle. Truncated when it runs long, which is what the icons in front are for. // is a riddle. Read out and shown on hover, since the line only draws their icons.
const summary = selected.length const names = picked.map(k => t(meta[k].labelKey)).join(', ')
? keys.filter(k => selected.includes(k)).map(k => t(meta[k].labelKey)).join(', ')
: t('roadtrip.poi.looking')
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
<button <Tooltip label={names} disabled={open || !picked.length}>
type="button" <button
onClick={() => setOpen(o => !o)} type="button"
aria-expanded={open} onClick={() => setOpen(o => !o)}
aria-haspopup="listbox" aria-expanded={open}
style={{ fontSize: FS.control }} aria-haspopup="listbox"
className="flex h-[32px] w-full items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint" aria-label={picked.length ? `${t('roadtrip.poi.looking')}: ${names}` : undefined}
> style={{ fontSize: FS.control }}
<span className="flex shrink-0 items-center gap-1"> className={`${DROPDOWN_TRIGGER} w-full`}
{keys.filter(k => selected.includes(k)).slice(0, 3).map(k => { >
const Icon = meta[k].Icon {picked.length ? (
return ( <span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<span {picked.map(k => {
key={k} const Icon = meta[k].Icon
className="grid h-[18px] w-[18px] place-items-center rounded-md" return (
// theme-lint-disable — the road-signage palette in `roadtripModel`. <span
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable — road-signage palette key={k}
> className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md"
<Icon size={10} strokeWidth={2.2} aria-hidden /> style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable: road-signage palette
</span> >
) <Icon size={10} strokeWidth={2.2} aria-hidden />
})} </span>
</span> )
<span className={`min-w-0 flex-1 truncate ${selected.length ? 'font-medium text-content' : 'text-content-faint'}`}> })}
{summary} </span>
</span> ) : (
<ChevronDown <span className="min-w-0 flex-1 truncate text-content-faint">{t('roadtrip.poi.looking')}</span>
size={14} )}
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} <ChevronDown
aria-hidden size={14}
/> className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
</button> aria-hidden
/>
</button>
</Tooltip>
{open ? ( {open ? (
<div <div role="listbox" aria-multiselectable className={`${DROPDOWN_LIST} start-0`}>
role="listbox"
aria-multiselectable
className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur"
>
{keys.map(key => { {keys.map(key => {
const { labelKey, Icon } = meta[key] const { labelKey, Icon } = meta[key]
const on = selected.includes(key) const on = selected.includes(key)
@@ -101,15 +89,15 @@ export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle
// Stays open: picking two kinds is one gesture, not two. // Stays open: picking two kinds is one gesture, not two.
onClick={() => onToggle(key)} onClick={() => onToggle(key)}
style={{ fontSize: FS.control }} style={{ fontSize: FS.control }}
className="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover" className={DROPDOWN_ROW}
> >
<span <span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]" className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable — road-signage palette style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable: road-signage palette
> >
<Icon size={12} strokeWidth={2} aria-hidden /> <Icon size={12} strokeWidth={2} aria-hidden />
</span> </span>
<span className={`min-w-0 flex-1 truncate ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}> <span className={`min-w-0 flex-1 truncate pe-4 ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
{t(labelKey)} {t(labelKey)}
</span> </span>
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null} {on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
@@ -138,6 +138,25 @@ const searchRow = (): HTMLButtonElement[] => {
} }
describe('RoadtripCorridorPanel', () => { describe('RoadtripCorridorPanel', () => {
it('FE-ROADTRIP-PANEL-036: the day sits beside what is looked for, not in the header, and picking one moves the search', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1), day(2, 2)])} />)
const dayTrigger = screen.getByText('Day 1').closest('button')!
expect(dayTrigger.closest('header, h2')).toBeNull()
// One line: the kinds and the day share the row under "Looking for".
const row = dayTrigger.parentElement!.parentElement!
expect(row.querySelector('[aria-haspopup="listbox"]')).not.toBe(dayTrigger)
expect(row.querySelectorAll('[aria-haspopup="listbox"]')).toHaveLength(2)
fireEvent.click(dayTrigger)
fireEvent.click(screen.getByRole('option', { name: /Day 2/ }))
expect(c.setDayId).toHaveBeenCalledWith('2')
})
it('FE-ROADTRIP-PANEL-037: a trip of one day offers no day to pick', () => {
wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1)])} />)
expect(screen.queryByText('Day 1')).toBeNull()
})
it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => { it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => {
const c = corridor() const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />) wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
@@ -512,8 +531,8 @@ describe('RoadtripCorridorPanel', () => {
}) })
it('FE-ROADTRIP-PANEL-032: a narrow panel gives the heading up before it gives up a control', () => { it('FE-ROADTRIP-PANEL-032: a narrow panel gives the heading up before it gives up a control', () => {
// The heading repeats what the column already is, and the day picker beside it is // The heading repeats what the column already is; the controls are what somebody came
// the part somebody came here to change. // here to change.
panelWidth.value = 320 panelWidth.value = 320
const { unmount } = wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />) const { unmount } = wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />)
expect(screen.getByRole('heading', { name: 'Along the route' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Along the route' })).toBeInTheDocument()
@@ -11,6 +11,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units' import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import RoadtripCategoryPicker from './RoadtripCategoryPicker' import RoadtripCategoryPicker from './RoadtripCategoryPicker'
import RoadtripDayPicker from './RoadtripDayPicker'
import { serviceColor } from './roadtripModel' import { serviceColor } from './roadtripModel'
import { alongLabel, offRouteLabel } from './corridorSearchModel' import { alongLabel, offRouteLabel } from './corridorSearchModel'
import { CORRIDOR_CATEGORY_BY_KEY, manualStopKindFor } from './stopKinds' import { CORRIDOR_CATEGORY_BY_KEY, manualStopKindFor } from './stopKinds'
@@ -296,9 +297,9 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi, onFocusP
* the list answers "which of these" and the map answers "which side of the road". * the list answers "which of these" and the map answers "which side of the road".
* *
* Three cards down one column, the same shapes the rail opposite uses: what to look for, * Three cards down one column, the same shapes the rail opposite uses: what to look for,
* how the search is getting on, and what it found. The day it searches sits in the * how the search is getting on, and what it found. The day it searches sits beside what
* header rather than among the filters — it is the question's subject, not one of its * it looks for, in the same dropdown, so the question reads as one line: these kinds,
* conditions. * along this day.
*/ */
export default function RoadtripCorridorPanel({ export default function RoadtripCorridorPanel({
corridor, routes, onAddPoi, onFocusPoint, onAddManual, tripId, canImport, corridor, routes, onAddPoi, onFocusPoint, onAddManual, tripId, canImport,
@@ -319,11 +320,6 @@ export default function RoadtripCorridorPanel({
const panel = useElementSize<HTMLDivElement>() const panel = useElementSize<HTMLDivElement>()
const narrow = panel.width > 0 && panel.width < NARROW_PANEL_PX const narrow = panel.width > 0 && panel.width < NARROW_PANEL_PX
const dayOptions = routes.days.map(d => ({
value: String(d.dayId),
label: t('roadtrip.day', { number: d.dayNumber }),
}))
const grouped = CORRIDOR_CATEGORY_KEYS const grouped = CORRIDOR_CATEGORY_KEYS
.map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) })) .map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) }))
.filter(g => g.pois.length > 0) .filter(g => g.pois.length > 0)
@@ -378,40 +374,37 @@ export default function RoadtripCorridorPanel({
return ( return (
<div ref={panel.ref} className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3"> <div ref={panel.ref} className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
{/* Header: the panel's name, and the day the question is about. {/* Header: the panel's name. Pulled at, it is the first thing to go: it repeats
what the column already is, and a heading that shortens to an ellipsis says less
Pulled at, the name is the first thing to go. It repeats what the column than no heading at all. */}
already is, the day picker beside it is the part somebody came here to change,
and a heading that shortens to an ellipsis says less than no heading at all. */}
<div className="flex flex-shrink-0 items-center gap-2.5 px-1"> <div className="flex flex-shrink-0 items-center gap-2.5 px-1">
{narrow ? null : ( {narrow ? null : (
<h2 className="min-w-0 truncate font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}> <h2 className="min-w-0 truncate font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}>
{t('roadtrip.poi.title')} {t('roadtrip.poi.title')}
</h2> </h2>
)} )}
{dayOptions.length > 1 ? ( {tripId !== undefined && tripId > 0 && canImport && (
<div className="ms-auto min-w-0"> <span className="ms-auto"><GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} /></span>
<CustomSelect )}
value={corridor.dayId}
onChange={value => corridor.setDayId(String(value))}
options={dayOptions}
size="sm"
/>
</div>
) : null}
{tripId !== undefined && tripId > 0 && canImport && <GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} />}
</div> </div>
{/* What to look for. */} {/* What to look for. */}
<div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}> <div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span> <span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span>
<RoadtripCategoryPicker <div className="flex items-center gap-2">
keys={CORRIDOR_CATEGORY_KEYS} <div className="min-w-0 flex-1">
meta={CATEGORY_META} <RoadtripCategoryPicker
selected={corridor.categories} keys={CORRIDOR_CATEGORY_KEYS}
onToggle={corridor.toggleCategory} meta={CATEGORY_META}
/> selected={corridor.categories}
onToggle={corridor.toggleCategory}
/>
</div>
{routes.days.length > 1 ? (
<RoadtripDayPicker days={routes.days} value={corridor.dayId} onChange={corridor.setDayId} />
) : null}
</div>
</div> </div>
<span className="h-px bg-edge-faint" aria-hidden /> <span className="h-px bg-edge-faint" aria-hidden />
@@ -0,0 +1,53 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import RoadtripDayPicker from './RoadtripDayPicker'
/**
* FE-DAYPICK-001..004 — the day the corridor searches along, in the same dropdown as what
* it looks for.
*/
const DAYS = [{ dayId: 11, dayNumber: 1 }, { dayId: 12, dayNumber: 2 }, { dayId: 13, dayNumber: 3 }]
function picker(value = '12', onChange = vi.fn()) {
render(<RoadtripDayPicker days={DAYS} value={value} onChange={onChange} />)
return { onChange, trigger: () => screen.getAllByRole('button')[0] }
}
describe('RoadtripDayPicker', () => {
it('FE-DAYPICK-001: closed, it names the picked day and nothing else', () => {
const { trigger } = picker()
expect(trigger()).toHaveTextContent('Day 2')
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('listbox')).toBeNull()
})
it('FE-DAYPICK-002: opened, it lists every day and marks the picked one', () => {
const { trigger } = picker()
fireEvent.click(trigger())
const options = screen.getAllByRole('option')
expect(options.map(o => o.textContent)).toEqual(['1Day 1', '2Day 2', '3Day 3'])
expect(options[1]).toHaveAttribute('aria-selected', 'true')
expect(options[0]).toHaveAttribute('aria-selected', 'false')
})
it('FE-DAYPICK-003: picking a day reports its id and closes, a single choice being one gesture', () => {
const { trigger, onChange } = picker()
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('option', { name: /Day 3/ }))
expect(onChange).toHaveBeenCalledWith('13')
expect(screen.queryByRole('listbox')).toBeNull()
})
it('FE-DAYPICK-004: a click outside or Escape closes it without a choice', () => {
const { trigger, onChange } = picker()
fireEvent.click(trigger())
fireEvent.mouseDown(document.body)
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(trigger())
fireEvent.keyDown(document, { key: 'Escape' })
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(onChange).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,75 @@
import React from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { DROPDOWN_LIST, DROPDOWN_ROW, DROPDOWN_TRIGGER, useDropdown } from './railDropdown'
import { FS } from './typeScale'
interface RoadtripDayPickerProps {
days: readonly { dayId: number; dayNumber: number }[]
/** The picked day's id, as the corridor keeps it. */
value: string
onChange: (dayId: string) => void
}
/**
* The day whose drive the corridor searches along, in the same dropdown as what it looks
* for and on the same line, so the question reads as one: these kinds, along this day.
* Each day leads with its number where a kind leads with its icon.
*/
export default function RoadtripDayPicker({ days, value, onChange }: RoadtripDayPickerProps): React.ReactElement {
const { t } = useTranslation()
const { open, setOpen, ref } = useDropdown<HTMLDivElement>()
const current = days.find(d => String(d.dayId) === value)
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-haspopup="listbox"
style={{ fontSize: FS.control }}
className={DROPDOWN_TRIGGER}
>
<span className="whitespace-nowrap font-medium text-content">
{current ? t('roadtrip.day', { number: current.dayNumber }) : null}
</span>
<ChevronDown
size={14}
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
{open ? (
<div role="listbox" className={`${DROPDOWN_LIST} end-0 max-h-[18rem] overflow-y-auto`}>
{days.map(day => {
const on = String(day.dayId) === value
return (
<button
key={day.dayId}
type="button"
role="option"
aria-selected={on}
onClick={() => { onChange(String(day.dayId)); setOpen(false) }}
style={{ fontSize: FS.control }}
className={DROPDOWN_ROW}
>
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px] bg-surface-tertiary font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{day.dayNumber}
</span>
<span className={`min-w-0 flex-1 truncate pe-4 ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
{t('roadtrip.day', { number: day.dayNumber })}
</span>
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
</button>
)
})}
</div>
) : null}
</div>
)
}
@@ -277,6 +277,59 @@ describe('RoadtripLimitsCard', () => {
fireEvent.click(toggle) fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false) expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
}) })
it('FE-ROADTRIP-LIMITS-018: starting and ending each day at the stay is off until it is asked for', () => {
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
// Missing is off: a trip drives as it always has until somebody switches it on.
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', true)
expect(onSave).toHaveBeenCalledTimes(1)
// What it does is said under it for everybody to read, and the switch is described by
// that sentence, so a screen reader hears it with the switch rather than never.
const hint = screen.getByText(
'After a booked night the day starts at that stay, and before one it ends at the stay booked for that night.',
)
expect(toggle).toHaveAttribute('aria-describedby', hint.id)
expect(hint.id).not.toBe('')
expect(screen.queryByRole('tooltip')).toBeNull()
// A switch that needs no sentence is described by none.
expect(screen.getByRole('button', { name: 'Connect the days' })).not.toHaveAttribute('aria-describedby')
})
it('FE-ROADTRIP-LIMITS-019: switched on, it reads on and reports the way back off', () => {
useSettingsStore.setState({
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', false)
})
it('FE-ROADTRIP-LIMITS-021: switched on, the button says so with the bed, instead of saying nothing is set', () => {
const off = render(<RoadtripLimitsCard onSave={vi.fn()} />)
expect(screen.getByText('None set')).toBeInTheDocument()
expect(off.container.querySelector('svg.lucide-bed-double')).toBeNull()
off.unmount()
useSettingsStore.setState({
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
})
const { container } = render(<RoadtripLimitsCard onSave={vi.fn()} />)
expect(screen.queryByText('None set')).toBeNull()
const bed = container.querySelector('svg.lucide-bed-double')!
expect(bed).toHaveAttribute('aria-label', 'Start and end each day at your stay')
// A sign alone: the switch has no figure to put beside it.
expect(bed.parentElement!.nextElementSibling).toBeNull()
expect(screen.getByRole('button')).toHaveAccessibleName(expect.stringContaining('Start and end each day at your stay'))
})
}) })
/** /**
@@ -313,6 +366,16 @@ describe('RoadtripLimitsCard', () => {
expect(screen.getByText('Connect the days')).toBeInTheDocument() expect(screen.getByText('Connect the days')).toBeInTheDocument()
expect(screen.getByText('A colour per day')).toBeInTheDocument() expect(screen.getByText('A colour per day')).toBeInTheDocument()
}) })
it('FE-ROADTRIP-LIMITS-020: the stay switch shows its row and offers no switch', () => {
useSettingsStore.setState({
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
})
open(undefined)
expect(screen.queryByRole('button', { name: 'Start and end each day at your stay' })).not.toBeInTheDocument()
expect(screen.getByText('Start and end each day at your stay')).toBeInTheDocument()
})
}) })
}) })
@@ -1,5 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useState } from 'react' import React, { useId, useState } from 'react'
import { import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check, Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette, Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
@@ -18,6 +18,9 @@ import { FS } from './typeScale'
import DayWindowFields from './DayWindowFields' import DayWindowFields from './DayWindowFields'
import SettingsHint from './SettingsHint' import SettingsHint from './SettingsHint'
import { dayWindow } from './dayWindow' import { dayWindow } from './dayWindow'
import { BOOKEND_ICON } from './nightBookend'
import { useHotelBookends } from './useHotelBookends'
import FigureBadge from './FigureBadge'
/** /**
* Everything that decides when the rail speaks up about the driving. * Everything that decides when the rail speaks up about the driving.
@@ -162,21 +165,30 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
* Disabled rather than hidden when there is no engine that can answer: an operator who * Disabled rather than hidden when there is no engine that can answer: an operator who
* pointed the instance at their own OSRM has no second engine, and a switch that flips * pointed the instance at their own OSRM has no second engine, and a switch that flips
* and changes nothing is worse than one that says why it cannot. * and changes nothing is worse than one that says why it cannot.
*
* A `hint` is the sentence a switch needs to say what flipping it changes. It stands under
* the label, in the caption the panels close on, and the switch is described by it: behind a
* hover it was a sentence nobody found, and a screen reader never heard.
*/ */
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: { function AvoidRow({ icon: Icon, label, hint, on, disabled, onToggle }: {
icon: typeof Coins icon: typeof Coins
label: string label: string
hint?: string
on: boolean on: boolean
disabled: boolean disabled: boolean
onToggle: () => void onToggle: () => void
}): React.ReactElement { }): React.ReactElement {
const hintId = useId()
return ( return (
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}> <div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden /> <Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span> <span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-body text-content-secondary">{label}</span>
{hint ? <span id={hintId} className="text-caption leading-snug text-content-faint">{hint}</span> : null}
</span>
{disabled {disabled
? <span className="text-caption text-content-faint">{'—'}</span> ? <span className="text-caption text-content-faint">{'—'}</span>
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />} : <ToggleSwitch on={on} onToggle={onToggle} label={label} describedBy={hint ? hintId : undefined} />}
</div> </div>
) )
} }
@@ -281,6 +293,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
// days gets the dialog with the trip's figures in it, not a form that takes a number // days gets the dialog with the trip's figures in it, not a form that takes a number
// and drops it. // and drops it.
const readOnly = !onSave const readOnly = !onSave
const bookends = useHotelBookends(onSave)
const legMinutes = settings.roadtrip_leg_minutes const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes const dayMinutes = settings.roadtrip_day_minutes
@@ -385,7 +398,10 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
avoiding.length && canAvoid avoiding.length && canAvoid
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) } ? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
: null, : null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[] // A switch has no figure, so its badge is the sign alone and its words are the tooltip.
// Without it a trip whose days start and end at the stay said "None set" on the button.
bookends.on ? { key: 'bookends', Icon: BOOKEND_ICON, label: t('roadtrip.line.hotelBookends') } : null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text?: string; label?: string }[]
return ( return (
<> <>
@@ -409,21 +425,13 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
</span> </span>
{badges.length ? ( {badges.length ? (
<span className="flex flex-wrap items-center gap-1"> <span className="flex flex-wrap items-center gap-1">
{badges.map(({ key, Icon, text }) => ( {badges.map(({ key, Icon, text, label }) => (
<span <FigureBadge
key={key} key={key}
className="inline-flex h-[16px] items-stretch overflow-hidden rounded border border-edge" lead={label ? <Icon size={9} aria-label={label} /> : <Icon size={9} aria-hidden />}
> value={text}
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint"> tooltip={label}
<Icon size={9} aria-hidden /> />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{text}
</span>
</span>
))} ))}
</span> </span>
) : ( ) : (
@@ -657,6 +665,18 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
disabled={readOnly} disabled={readOnly}
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)} onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
/>} />}
{/* Where a day begins and ends rather than what joins them: after a booked
night the drive sets off from the stay, and before one it ends there.
Off by default, because it adds a leg to most days of a trip that has
stays and changes their kilometres and times. */}
<AvoidRow
icon={BOOKEND_ICON}
label={t('roadtrip.line.hotelBookends')}
hint={t('roadtrip.line.hotelBookendsHint')}
on={bookends.on}
disabled={!bookends.toggle}
onToggle={() => bookends.toggle?.()}
/>
{/* Which matters most once the line IS continuous: end to end it is one {/* Which matters most once the line IS continuous: end to end it is one
stroke, and a colour per day is what puts the days back into it. */} stroke, and a colour per day is what puts the days back into it. */}
<AvoidRow <AvoidRow
@@ -1,7 +1,15 @@
import React from 'react' import React from 'react'
import { describe, it, expect, vi } from 'vitest' import { afterEach, describe, it, expect, vi } from 'vitest'
import { render, screen, within, fireEvent } from '@testing-library/react' import { act, render, screen, within, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n' import { TranslationProvider } from '../../i18n'
// The rail measures itself to drop the stop counts when pulled narrow. Zero reads as "not
// measured yet", which keeps every figure, and is the default here.
const { railWidth } = vi.hoisted(() => ({ railWidth: { value: 0 } }))
vi.mock('../../hooks/useElementSize', () => ({
useElementSize: () => ({ ref: () => {}, width: railWidth.value, height: 0 }),
}))
afterEach(() => { railWidth.value = 0 })
import RoadtripSidebar from './RoadtripSidebar' import RoadtripSidebar from './RoadtripSidebar'
import RoadtripModeSwitch from './RoadtripModeSwitch' import RoadtripModeSwitch from './RoadtripModeSwitch'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
@@ -80,6 +88,31 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
} }
} }
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
describe('RoadtripSidebar', () => { describe('RoadtripSidebar', () => {
/** /**
* The value the summary head pairs with this label. The same words also name the chips * The value the summary head pairs with this label. The same words also name the chips
@@ -102,6 +135,22 @@ describe('RoadtripSidebar', () => {
expect(total('Stops')).toBe('5') expect(total('Stops')).toBe('5')
}) })
it('FE-ROADTRIP-SIDEBAR-083: pulled narrow, the rail stops counting stops, in its head and on every day', () => {
const wide = wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
expect(screen.getByText('2 stops')).toBeInTheDocument()
wide.unmount()
railWidth.value = 300
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
expect(total('Distance')).toBe('250 km')
expect(total('Driving time')).toBe('2 h 30 min')
const head = screen.getByText('Distance').closest('header')!
// The figure goes with its hairline, so the two left share the width.
expect(within(head).queryByText('Stops')).toBeNull()
expect(head.querySelectorAll('span.w-px')).toHaveLength(1)
expect(screen.queryByText('2 stops')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => { it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
// Trip and day totals deliberately differ from the single leg, so the leg's own two // Trip and day totals deliberately differ from the single leg, so the leg's own two
// values are the only place '100 km' and '1 h' can come from. // values are the only place '100 km' and '1 h' can come from.
@@ -585,11 +634,16 @@ describe('RoadtripSidebar', () => {
expect(onReorderStop).not.toHaveBeenCalled() expect(onReorderStop).not.toHaveBeenCalled()
}) })
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => { it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car, a ride keeps its own', () => {
const ferry = leg({ mode: 'ferry' }) const unknown = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'hovercraft' })] })] })} />)
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />) expect(unknown.container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
unknown.unmount()
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument() // A ferry leg is a booked ride since the carrier seams (#2429), not a drive
// whose mode nobody drew an icon for.
const ride = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [leg({ mode: 'ferry' })] })] })} />)
expect(ride.container.querySelector('svg.lucide-sailboat')).toBeInTheDocument()
expect(ride.container.querySelector('svg.lucide-car-front')).toBeNull()
}) })
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => { it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
@@ -706,31 +760,6 @@ describe('RoadtripSidebar', () => {
* point the tank ran dry. A station offered at the warning is one the car cannot reach. * point the tank ran dry. A station offered at the warning is one the car cannot reach.
*/ */
describe('the leg the tank runs out on', () => { describe('the leg the tank runs out on', () => {
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({ const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
osm_id: `osm-${name}`, osm_id: `osm-${name}`,
name, name,
@@ -1131,13 +1160,24 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
}) })
} }
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes over both terminals with their timetable, and no number', () => { it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes, both codes with their timetable, the places and the check-in, and no number', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />) wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
expect(screen.getByText('1 h 10 min')).toBeInTheDocument()
expect(screen.queryByText('LH 2020 · 1 h 10 min')).toBeNull()
// Each code, each timetable clock and each place once.
expect(screen.getAllByText('HAM')).toHaveLength(1)
expect(screen.getAllByText('MUC')).toHaveLength(1)
expect(screen.getAllByText('13:20')).toHaveLength(1)
expect(screen.getAllByText('14:30')).toHaveLength(1)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument() expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument() expect(screen.getByText('Munich Airport')).toBeInTheDocument()
expect(screen.getByText('Departure 13:20')).toBeInTheDocument() // What the clocks are is said to a screen reader, not printed a second time.
expect(screen.getByText('Arrival 14:30')).toBeInTheDocument() expect(screen.getByText('Departure 13:20')).toHaveClass('sr-only')
expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument() expect(screen.getByText('Arrival 14:30')).toHaveClass('sr-only')
// The check-in, labelled, where an unlabelled 12:20 used to stand in the arrival column.
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('12:20')).toBeInTheDocument()
// Hamburg is 1 and Munich is 2: the terminals between them take no number, and // Hamburg is 1 and Munich is 2: the terminals between them take no number, and
// the day header counts two stops. // the day header counts two stops.
expect(screen.queryByText('3')).not.toBeInTheDocument() expect(screen.queryByText('3')).not.toBeInTheDocument()
@@ -1149,9 +1189,9 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
const onAskAlternatives = vi.fn() const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />) 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. // One control for the whole ride, not one per terminal and one for the band.
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1) expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
fireEvent.click(screen.getByText('Hamburg Airport')) fireEvent.click(screen.getByText('Hamburg Airport'))
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min')) fireEvent.click(screen.getByText('LH 2020'))
expect(onOpenBooking).toHaveBeenCalledTimes(2) expect(onOpenBooking).toHaveBeenCalledTimes(2)
expect(onOpenBooking).toHaveBeenCalledWith(70) expect(onOpenBooking).toHaveBeenCalledWith(70)
// One shuffle: the road into the departure terminal. Not the ride, not the road out // One shuffle: the road into the departure terminal. Not the ride, not the road out
@@ -1159,6 +1199,17 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1) expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
}) })
it('FE-ROADTRIP-SIDEBAR-059: asking names the drive as a leg of its card, not as a bare position', () => {
// A tagged drive rather than an index, so the road arriving at the head of a card can
// be asked about as what it is instead of under an index no stop has.
const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={vi.fn()} onAskAlternatives={onAskAlternatives} />)
fireEvent.click(screen.getByLabelText('Other ways'))
expect(onAskAlternatives).toHaveBeenCalledWith(1, { kind: 'leg', index: 0 })
})
it('FE-ROADTRIP-SIDEBAR-051: a ride landing tomorrow leaves its departure as a row of its own, with the road into it', () => { 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 stops = [stop({ assignmentId: 1, name: 'Hamburg' }), terminal('departure')]
const overnight = day({ const overnight = day({
@@ -1173,12 +1224,68 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
}, },
}) })
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />) wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument() // The block's pieces on one row: the code and the place, the timetable's clock on the
expect(screen.getByText('Departure 13:20')).toBeInTheDocument() // right, and the check-in under them.
expect(screen.queryByText('LH 2020 · 1 h 10 min')).not.toBeInTheDocument() const row = screen.getByText('Hamburg Airport').closest('button')!
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1) expect(within(row).getByText('HAM')).toBeInTheDocument()
expect(within(row).getByText('13:20')).toBeInTheDocument()
expect(within(row).getByText('Check-in')).toBeInTheDocument()
expect(within(row).getByText('12:20')).toBeInTheDocument()
// The chain's arrival there is the check-in, not a second clock in the column.
expect(screen.queryByText('21:00')).toBeNull()
expect(screen.queryByText('LH 2020')).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1) expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
}) })
/** The card a ride lands on, joined to the card before it by the ride itself. */
const landingDay = (arrivingLeg: RouteSegment) => {
const arrival = terminal('arrival')
return day({
dayId: 2,
dayNumber: 2,
stops: [{ ...arrival, time: '07:00', carrier: { ...arrival.carrier!, at: '07:00' } }, stop({ assignmentId: 2, name: 'Munich' })],
legs: [leg()],
arrivingLeg,
schedule: {
entries: [
{ arrival: '07:00', departure: '07:00', anchored: true, dayOffset: 0 },
{ arrival: '08:00', departure: '08:00', anchored: false, dayOffset: 0 },
],
warnings: [],
},
})
}
it('FE-ROADTRIP-SIDEBAR-054: the join into the day a ride lands on is the ride, not a car driving no distance', () => {
const flown = leg({ mode: 'flight', distance: 0, duration: 9 * 3600, distanceText: '', durationText: '9 h' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(flown)], totalStops: 1 })} />)
// The booking and its minutes, the minutes standing apart so a long title cannot cut them off.
const band = screen.getByText('LH 2020').parentElement!
expect(within(band).getByText('9 h')).toHaveClass('shrink-0')
expect(screen.queryByText(/0 m in 9 h/)).not.toBeInTheDocument()
// Under the booking's own icon, not the car the road out of the airport wears.
expect(band.querySelector('svg.lucide-plane')).toBeInTheDocument()
expect(band.querySelector('svg.lucide-car-front')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-055: a ride without a timetable still names the booking rather than passing for a hop', () => {
const untimed = leg({ mode: 'flight', distance: 0, duration: 0, distanceText: '', durationText: '' })
wrap(<RoadtripSidebar routes={routes({ days: [landingDay(untimed)], totalStops: 1 })} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-056: the tank starts afresh after a flight, and the road out of the arrival can be the one it runs out on', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...flightDay(), dryPoints: [dry({ legIndex: 2 })] }], totalStops: 2 })} refuel={search()} />,
)
// Hamburg, the ride block, Munich: the band hangs under the block, where the road
// out of the arrival leaves, and nowhere else.
const rows = container.querySelectorAll('li')
expect(rows).toHaveLength(3)
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
}) })
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => { describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
@@ -1194,9 +1301,9 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const booking = (over: Partial<Reservation>): Reservation => const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation ({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => { const rentalDay = () => {
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')] const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
const rental = day({ return day({
stops, stops,
legs: [leg(), leg(), leg()], legs: [leg(), leg(), leg()],
schedule: { schedule: {
@@ -1209,11 +1316,22 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
warnings: [], warnings: [],
}, },
}) })
}
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const onOpenBooking = vi.fn() const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />) wrap(<RoadtripSidebar routes={routes({ days: [rentalDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument() // Each desk says which one it is, having no other end beside it, and its clock once.
expect(screen.getByText('Return 11:30')).toBeInTheDocument() const pickup = screen.getByText('Sixt Hauptbahnhof').closest('button')!
expect(screen.getByText('HAM')).toBeInTheDocument() expect(within(pickup).getByText('Pickup')).toBeInTheDocument()
expect(within(pickup).getAllByText('09:00')).toHaveLength(1)
expect(within(pickup).getByText('Pick-up 09:00')).toHaveClass('sr-only')
const drop = screen.getByText('Sixt Airport').closest('button')!
expect(within(drop).getByText('Return')).toBeInTheDocument()
expect(within(drop).getAllByText('11:30')).toHaveLength(1)
expect(within(drop).getByText('HAM')).toBeInTheDocument()
// A desk has no check-in.
expect(screen.queryByText('Check-in')).toBeNull()
expect(screen.queryByText('3')).not.toBeInTheDocument() expect(screen.queryByText('3')).not.toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument() expect(screen.getByText('2 stops')).toBeInTheDocument()
fireEvent.click(screen.getByText('Sixt Airport')) fireEvent.click(screen.getByText('Sixt Airport'))
@@ -1223,6 +1341,15 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2) expect(screen.getAllByLabelText('Other ways')).toHaveLength(2)
}) })
it('FE-ROADTRIP-SIDEBAR-057: a tank that empties on the road out of the pick-up desk is offered a fill-up under the desk', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [{ ...rentalDay(), dryPoints: [dry({ legIndex: 0 })] }], totalStops: 2 })} refuel={search()} />,
)
const rows = container.querySelectorAll('li')
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[0] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => { 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 stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [ const reservations = [
@@ -1233,7 +1360,7 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }), booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }),
] ]
const onOpenBooking = vi.fn() const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />) wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} canEditBookings />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument() expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument() expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument() expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
@@ -1247,4 +1374,443 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
// The chips are the stop's, not stops: the count stays at two. // The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument() expect(screen.getByText('2 stops')).toBeInTheDocument()
}) })
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
booking({ id: 12, title: 'Taxi zum Hafen', type: 'taxi', reservation_time: '2026-10-05T18:30', assignment_id: 1 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
// The table is there to read, not a button that does nothing; the taxi is a button.
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpenBooking).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi zum Hafen'))
expect(onOpenBooking).toHaveBeenCalledWith(12)
})
})
describe('RoadtripSidebar with the drive in from the day before (#2461)', () => {
/** Where day 1 ended, three stops in, and a card joined to it by the road from there. */
const yesterday = stop({ assignmentId: 9, name: 'Lüneburg', ownerDayId: 1, ownerIndex: 2 })
const joined = (over: Partial<RoadtripDay> = {}) => day({
dayId: 2,
dayNumber: 2,
arrivingLeg: leg({ distance: 120000, duration: 5400, distanceText: '120 km', durationText: '1 h 30 min' }),
arrivingFrom: yesterday,
arrivingLine: [[53.2, 10.4], [52.5, 13.4]],
...over,
})
const pressed = () => screen.getAllByLabelText('Other ways').map(icon => icon.closest('button')?.getAttribute('aria-pressed'))
it('FE-ROADTRIP-SIDEBAR-060: the drive in offers other ways as the drive it is, and shows pressed while they are open', () => {
// #2461: the band above the first stop was the one drive on the rail without the
// control, so the road between two days could not be changed at all.
const onAskAlternatives = vi.fn()
const first = wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={onAskAlternatives} />)
// The drive in, then the card's own leg.
expect(pressed()).toEqual(['false', 'false'])
fireEvent.click(screen.getAllByLabelText('Other ways')[0])
expect(onAskAlternatives).toHaveBeenCalledWith(2, { kind: 'arriving' })
first.unmount()
const open = wrap(
<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={vi.fn()} openAlternatives={{ dayId: 2, drive: { kind: 'arriving' } }} />,
)
expect(pressed()).toEqual(['true', 'false'])
open.unmount()
// The card's first leg is another drive, even at the index the drive in would borrow.
wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={vi.fn()} openAlternatives={{ dayId: 2, drive: { kind: 'leg', index: 0 } }} />)
expect(pressed()).toEqual(['false', 'true'])
})
it('FE-ROADTRIP-SIDEBAR-061: no control for a reader, for a drive from a terminal, or for one the rail has no line for', () => {
const reader = wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} />)
expect(screen.queryAllByLabelText('Other ways')).toHaveLength(0)
// The band is still drawn, only not as a control.
expect(screen.getByText('120 km in 1 h 30 min')).toBeInTheDocument()
reader.unmount()
const landed = stop({
assignmentId: -3000000141,
name: 'Munich Airport',
carrier: { reservationId: 70, type: 'flight', role: 'arrival', title: 'LH 2020', code: 'MUC', at: '14:30' },
})
const fromTerminal = wrap(<RoadtripSidebar routes={routes({ days: [joined({ arrivingFrom: landed })] })} onAskAlternatives={vi.fn()} />)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
fromTerminal.unmount()
wrap(<RoadtripSidebar routes={routes({ days: [joined({ arrivingLine: undefined })] })} onAskAlternatives={vi.fn()} />)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
describe('a ride on no day (#2461)', () => {
const terminals = [
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null },
]
const ferry = (over: Partial<Reservation>): Reservation =>
({ id: 70, trip_id: 1, type: 'ferry', title: 'IJmuiden to Newcastle', status: 'confirmed', day_id: null, endpoints: terminals, ...over }) as unknown as Reservation
it('FE-ROADTRIP-SIDEBAR-062: is named under the totals with its booking a click away; one on a day or without terminals is not', () => {
const onOpenBooking = vi.fn()
const reservations = [
ferry({}),
// On a day it is on the drive already; without terminals nothing says where it runs.
ferry({ id: 71, title: 'Dated ferry', day_id: 1 }),
ferry({ id: 72, title: 'Unlocated ferry', endpoints: [] } as Partial<Reservation>),
ferry({ id: 73, title: 'Table', type: 'restaurant' }),
]
wrap(<RoadtripSidebar routes={routes()} reservations={reservations} onOpenBooking={onOpenBooking} />)
const notice = screen.getByRole('status')
expect(within(notice).getByText('IJmuiden to Newcastle is on none of this trip’s days, so the drive does not use it.')).toBeInTheDocument()
expect(within(notice).getAllByRole('listitem')).toHaveLength(1)
fireEvent.click(within(notice).getByRole('button', { name: 'Open booking' }))
expect(onOpenBooking).toHaveBeenCalledWith(70)
})
it('FE-ROADTRIP-SIDEBAR-063: says so without a button where bookings cannot be opened, and says nothing when every ride has a day', () => {
const reader = wrap(<RoadtripSidebar routes={routes()} reservations={[ferry({})]} />)
expect(screen.getByText('IJmuiden to Newcastle is on none of this trip’s days, so the drive does not use it.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open booking' })).toBeNull()
reader.unmount()
wrap(<RoadtripSidebar routes={routes()} reservations={[ferry({ day_id: 1 })]} onOpenBooking={vi.fn()} />)
expect(screen.queryByRole('status')).toBeNull()
})
})
})
describe('RoadtripSidebar with a booked night at the edge of the day', () => {
const bookend = (phase: 'morning' | 'evening', over: Partial<NonNullable<RoadtripStop['bookend']>> = {}, at: Partial<RoadtripStop> = {}) =>
stop({
assignmentId: phase === 'morning' ? -6_000_000_002 : -6_000_000_003,
name: 'Hotel Alpenblick',
placeId: 900,
lat: 47.2,
lng: 11.4,
stopType: 'hotel',
dwellMinutes: 0,
bookend: { phase, accommodationId: 5, reservationId: 41, checkingOut: false, checkingIn: false, checkOut: null, ...over },
...at,
})
/** Out of the hotel on its check-out morning, two places, and into the night's hotel. */
const loop = (over: Partial<RoadtripDay> = {}) => {
const stops = [
bookend('morning', { checkingOut: true, checkOut: '10:00' }, { ownerIndex: 0 }),
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
stop({ assignmentId: 2, name: 'Falls', ownerIndex: 1 }),
bookend('evening', {}, { ownerIndex: 2 }),
]
return day({
stops,
legs: [leg(), leg(), leg()],
schedule: {
entries: ['08:40', '09:40', '10:40', '11:40'].map(arrival => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })),
warnings: [],
},
...over,
})
}
it('FE-ROADTRIP-SIDEBAR-064: names the hotel it sets out from and the one it ends at, and counts neither', () => {
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
// The hotel is the row's name; which edge of the stay it is, and the hour, sit in its badge.
expect(screen.getAllByText('Hotel Alpenblick')).toHaveLength(2)
expect(screen.getByText('Check-out').closest('button')).toHaveTextContent('10:00')
expect(screen.getByText('Overnight')).toBeInTheDocument()
expect(screen.queryByText(/·/)).toBeNull()
expect(screen.getByText('2 stops')).toBeInTheDocument()
// The places are one and two; the hotel wears no number.
expect(screen.getByText('Lookout').closest('button')).toHaveTextContent('1')
expect(screen.getByText('11:40')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-074: a check-out morning left after the room is handed back says so, one left in time does not', () => {
const late = loop({
schedule: {
entries: ['12:27', '13:27', '14:27', '15:27'].map(arrival => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })),
warnings: [],
},
})
// The check-out badge itself turns, instead of a sentence added under the row.
const { unmount } = wrap(<RoadtripSidebar routes={routes({ days: [late] })} />)
expect(screen.getByText('Check-out').className).toContain('text-warning')
unmount()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
expect(screen.getByText('Check-out').className).not.toContain('text-warning')
})
it('FE-ROADTRIP-SIDEBAR-065: the hotel rows are neither dragged nor dropped on, and carry no stay, kind or fill control', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={vi.fn()} onEditStay={vi.fn()} onSetStopKind={vi.fn()} onSetStopFill={vi.fn()} />,
)
expect(container.querySelectorAll('li[draggable="true"]')).toHaveLength(2)
const hotelRow = screen.getByText('Overnight').closest('li')!
expect(hotelRow).not.toHaveAttribute('draggable')
// The row is one button, with nothing inside it to press.
expect(within(hotelRow).getAllByRole('button')).toHaveLength(1)
expect(within(hotelRow).queryByText('Stay')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-066: offers other ways on the drive between the places only', () => {
const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onAskAlternatives={onAskAlternatives} />)
const offered = screen.getAllByLabelText('Other ways')
expect(offered).toHaveLength(1)
fireEvent.click(offered[0])
expect(onAskAlternatives).toHaveBeenCalledWith(1, { kind: 'leg', index: 1 })
})
it('FE-ROADTRIP-SIDEBAR-067: opens the booking behind the night for an editor, the hotel for anybody else', () => {
const onOpenBooking = vi.fn()
const onSelectStop = vi.fn()
const editor = wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} canEditBookings />)
fireEvent.click(screen.getByText('Overnight'))
expect(onOpenBooking).toHaveBeenCalledWith(41)
expect(onSelectStop).not.toHaveBeenCalled()
editor.unmount()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} />)
fireEvent.click(screen.getByText('Check-out'))
// The place alone, without an assignment: the hotel is no stop of the day.
expect(onSelectStop).toHaveBeenCalledWith(900)
expect(onOpenBooking).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-SIDEBAR-068: a place is not moved past the hotel at either end of its day', () => {
const onReorderStop = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={onReorderStop} />)
fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowUp', altKey: true })
fireEvent.keyDown(screen.getByText('Falls').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).not.toHaveBeenCalled()
fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).toHaveBeenCalledWith(1, 1, 1)
})
it('FE-ROADTRIP-SIDEBAR-069: the morning marker at the hotel is drawn as the hotel row, not as a line of its own', () => {
const resume = stop({
assignmentId: -2_000_000_003,
name: 'Continue journey',
lat: 47.2,
lng: 11.4,
automaticNight: { phase: 'start', fromDayNumber: 1 },
})
const stops = [resume, bookend('morning', {}, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg({ distance: 0 }), leg()] })] })} />)
expect(screen.getByText('Day start')).toBeInTheDocument()
expect(screen.queryByText('Continue journey')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-070: a drive to the hotel over the limit is flagged on the hotel row', () => {
wrap(<RoadtripSidebar routes={routes({ days: [loop({ driveWarnings: [{ index: 3, code: 'leg', overMinutes: 30 }] })] })} />)
const hotelRow = screen.getByText('Overnight').closest('li')!
expect(within(hotelRow).getByText('+30 min')).toBeInTheDocument()
})
/** Whether a row's disc has the rail running into it from above. */
const lineAbove = (text: string): boolean => {
const rail = screen.getByText(text).closest('button')!.firstElementChild!
return rail.firstElementChild!.className.includes('w-[1.5px]')
}
it('FE-ROADTRIP-SIDEBAR-071: the hotel row the morning marker went into starts the rail, with no line running in from nowhere', () => {
const resume = stop({
assignmentId: -2_000_000_003,
name: 'Continue journey',
lat: 47.2,
lng: 11.4,
automaticNight: { phase: 'start', fromDayNumber: 1 },
})
const stops = [resume, bookend('morning', { checkingOut: true }, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg({ distance: 0 }), leg()] })] })} />)
expect(lineAbove('Check-out')).toBe(false)
// Below it the rail runs on as always.
expect(lineAbove('Lookout')).toBe(true)
})
/** A transfer day: out of one stay and into the next, with nothing stored in between. */
const transfer = () => day({
dayId: 2,
dayNumber: 2,
stops: [
bookend('morning', { checkingOut: true }, { ownerIndex: 0 }),
bookend('evening', { accommodationId: 6, checkingIn: true }, { ownerIndex: 0, name: 'Wallinga', lat: 48, lng: 12 }),
],
legs: [leg()],
})
it('FE-ROADTRIP-SIDEBAR-072: a day that only drives from one stay to the next says its drive and no count of stops', () => {
wrap(<RoadtripSidebar routes={routes({ days: [transfer()] })} />)
expect(screen.getByText('Check-in').closest('button')).toHaveTextContent('Wallinga')
const header = screen.getByText('Day 2').closest('header')!
expect(within(header).getByText(/100 km/)).toBeInTheDocument()
expect(within(header).queryByText(/stops?$/)).toBeNull()
expect(screen.queryByText('0 stops')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-073: a stop dragged onto such a day lands as its first stop, dropped on either hotel', () => {
const onMoveStopToDay = vi.fn()
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [day(), transfer()] })} onReorderStop={vi.fn()} onMoveStopToDay={onMoveStopToDay} />,
)
const hotelRow = screen.getByText('Wallinga').closest('li')!
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
fireEvent.dragOver(hotelRow)
expect(hotelRow.className).toContain('ring-accent')
fireEvent.drop(hotelRow)
// From day 1, its second stop (assignment 2), onto day 2 as its first.
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 2, 0)
})
it('FE-ROADTRIP-SIDEBAR-081: the hotel at the edge of the day wears the hotel stop\'s own disc, not a terminal\'s', () => {
const stops = [
bookend('morning', { checkingOut: true }, { ownerIndex: 0 }),
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
stop({ assignmentId: 2, name: 'Gasthof Post', ownerIndex: 1, stopType: 'hotel', lat: 48, lng: 12 }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
const disc = (row: HTMLElement) => row.querySelector('svg.lucide-bed-double')!.parentElement as HTMLElement
const hotelStop = disc(screen.getByText('Gasthof Post').closest('div.grid') as HTMLElement)
const edge = disc(screen.getByText('Check-out').closest('button')!)
expect(edge.style.background).not.toBe('')
expect(edge.style.background).toBe(hotelStop.style.background)
expect(edge.style.color).toBe(hotelStop.style.color)
expect(edge.className).not.toContain('bg-surface-tertiary')
})
it('FE-ROADTRIP-SIDEBAR-082: the own stop of a stay on its check-in day carries the check-in hour as a badge', () => {
const stops = [
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
stop({ assignmentId: 2, name: 'Gasthof Post', ownerIndex: 1, stopType: 'hotel', night: true, checkInTime: '15:00' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg()] })] })} />)
const hotel = screen.getByText('Gasthof Post').closest('li')!
expect(within(hotel).getByText('Check-in').closest('.flex-wrap')).toHaveTextContent('15:00')
expect(within(screen.getByText('Lookout').closest('li')!).queryByText('Check-in')).toBeNull()
})
})
describe('RoadtripSidebar with a flight the drive gets to late (#2460)', () => {
/** LH 2078 as the routing round seats it: pinned an hour ahead for the check-in, named the way every airport writer names an endpoint. */
const end = (role: 'departure' | 'arrival', type = 'flight') =>
stop({
assignmentId: role === 'departure' ? -3000000780 : -3000000781,
name: role === 'departure' ? 'Hamburg (HAM)' : 'Munich (MUC)',
placeId: -78,
lat: role === 'departure' ? 53.63 : 48.35,
lng: role === 'departure' ? 9.99 : 11.78,
time: role === 'departure' ? '14:15' : '17:20',
dwellMinutes: role === 'departure' ? 60 : 0,
carrier: {
reservationId: 78,
type,
role,
title: 'LH 2078 HAM-MUC (ohne Endpunkte)',
code: role === 'departure' ? 'HAM' : 'MUC',
at: role === 'departure' ? '15:15' : '17:20',
},
})
/** Out of the Hamburg hotel, the flight, into the Munich one, with the drive to the airport `late` minutes past check-in. */
const flightDay = (late: number | null, type = 'flight') => {
const stops = [stop({ assignmentId: 1, name: 'Hotel Atlantic' }), end('departure', type), end('arrival', type), stop({ assignmentId: 2, name: 'Hotel Bayerischer Hof' })]
return day({
dayId: 3,
dayNumber: 3,
stops,
legs: [leg(), leg({ mode: type, distance: 0, duration: 7500, distanceText: '', durationText: '2 h 5 min' }), leg()],
schedule: {
entries: [
{ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 },
{ arrival: '14:15', departure: '15:15', anchored: true, dayOffset: 0 },
{ arrival: '17:20', departure: '17:20', anchored: true, dayOffset: 0 },
{ arrival: '18:00', departure: '18:00', anchored: false, dayOffset: 0 },
],
warnings: late === null ? [] : [{ index: 1, code: 'late' as const, minutes: late }],
},
})
}
const block = () => screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!.children[1] as HTMLElement
it('FE-ROADTRIP-SIDEBAR-075: a missed flight says so on its check-in and on the day, with no delta against a clock nobody sees', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
// The title as typed, the duration beside the check-in, each code once and the places without it.
const title = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)')
expect(title.className).not.toContain('uppercase')
expect(within(block()).getByText('Missed').closest('.flex-wrap')).toHaveTextContent('2 h 5 min')
expect(title.parentElement).not.toHaveTextContent('2 h 5 min')
expect(screen.getAllByText('HAM')).toHaveLength(1)
expect(screen.getByText('Hamburg')).toBeInTheDocument()
expect(screen.getByText('Munich')).toBeInTheDocument()
expect(screen.queryByText(/\(HAM\)/)).toBeNull()
// The check-in carries the finding; the separate late pill is gone.
expect(within(block()).getByText('Missed')).toBeInTheDocument()
expect(within(block()).getByText('there at 23:54')).toBeInTheDocument()
expect(screen.queryByText('+9 h 39 min')).toBeNull()
expect(block().className).toContain('border-warning')
const header = screen.getByText('Day 3').closest('header')!
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-076: a folded day still says its flight is missed', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} collapsedDayIds={new Set([3])} onToggleDay={vi.fn()} />)
const header = screen.getByText('Day 3').closest('header')!
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-077: late for the check-in but in time for the flight says by how much, and leaves the day alone', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(25)], totalStops: 2 })} />)
expect(within(block()).getByText('Check-in')).toBeInTheDocument()
expect(within(block()).getByText('25 min late')).toBeInTheDocument()
expect(block().className).not.toContain('border-warning')
expect(screen.queryByText('Flight missed')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-078: a train is boarded, and a missed one is named as a train', () => {
const onTime = wrap(<RoadtripSidebar routes={routes({ days: [flightDay(null, 'train')], totalStops: 2 })} />)
expect(screen.getByText('Boarding')).toBeInTheDocument()
expect(screen.getByText('14:15')).toBeInTheDocument()
onTime.unmount()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579, 'train')], totalStops: 2 })} />)
expect(within(screen.getByText('Day 3').closest('header')!).getByText('Train missed')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-079: the badge explains itself in a sentence, and a booking clock names what it is, not a time you set', () => {
vi.useFakeTimers()
try {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} />)
const hover = (el: HTMLElement) => {
fireEvent.mouseEnter(el)
act(() => { vi.advanceTimersByTime(300) })
}
hover(screen.getByText('there at 23:54').parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent(
'The drive reaches Hamburg at 23:54. LH 2078 HAM-MUC (ohne Endpunkte) departs at 15:15, check-in closes at 14:15.',
)
fireEvent.mouseLeave(screen.getByText('there at 23:54').parentElement!)
hover(screen.getByText('15:15').parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent('Departure 15:15')
expect(screen.queryByText('Time you set')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('FE-ROADTRIP-SIDEBAR-080: a screen reader hears the ride, its clocks and that it is missed, then what pressing it does', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
const button = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!
expect(button).not.toHaveAttribute('aria-label')
expect(button).toHaveAccessibleName(expect.stringMatching(/Departure 15:15\s*Arrival 17:20.*Missed\s*there at 23:54\s*2 h 5 min\s*Open booking$/))
})
}) })
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { alternativeSubline, alternativesPhase, buildAlternativeOverlays, type AlternativeOverlay } from './alternativeOverlays' import { alternativeSubline, alternativesBusy, alternativesPhase, buildAlternativeOverlays, otherEngineNote, type AlternativeOverlay } from './alternativeOverlays'
import { formatDurationShort } from './roadtripModel' import { formatDurationShort } from './roadtripModel'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors' import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator' import type { RouteAlternative } from '../Map/RouteCalculator'
/** /**
* FE-ALTOVL-001..017: turning the router's answers into something drawable. * FE-ALTOVL-001..023: turning the router's answers into something drawable.
* *
* Two decisions live here and neither is cosmetic: which of the offered roads is * Two decisions live here and neither is cosmetic: which of the offered roads is
* drawn as the one you are on, and where each label hangs. A label anchored on a * drawn as the one you are on, and where each label hangs. A label anchored on a
@@ -221,6 +221,56 @@ describe('buildAlternativeOverlays', () => {
expect(out[0].note).toBe('Current') expect(out[0].note).toBe('Current')
expect(out[1].color).toBe(ALT_SECONDARY) expect(out[1].color).toBe(ALT_SECONDARY)
}) })
it('FE-ALTOVL-018: the blue belongs to the road being driven, never to the quickest offer', () => {
// The picker always lists the rail's road as the current one. After a choice took, the
// old road came back as the router's quickest and was painted blue as if still driven.
const out = buildAlternativeOverlays(
[
alt({ duration: 11_400, coordinates: line(20, 0), current: true }),
alt({ duration: 10_800, coordinates: line(20, 1) }),
alt({ duration: 12_000, coordinates: line(20, 2) }),
],
LABELS,
)
expect(out.map(o => o.color)).toEqual([ALT_PRIMARY, ALT_SECONDARY, ALT_SECONDARY])
expect(out.map(o => o.note)).toEqual(['Current', 'Fastest', ''])
// Current takes part in the comparison: it is the rail's engine's own figure.
expect(out[0].slowerThanQuickest).toBe(600)
})
it('FE-ALTOVL-019: on a leg the second engine drives, its offers are the comparable ones', () => {
// A trip that avoids something is routed by Valhalla, rail and offers alike. Read
// against OSRM they would all be marked as another engine's and none could be timed.
const out = buildAlternativeOverlays(
[
alt({ duration: 6_778, coordinates: line(20, 0), current: true, engine: 'valhalla' }),
alt({ duration: 6_400, coordinates: line(20, 1), engine: 'valhalla' }),
alt({ duration: 5_600, coordinates: line(20, 2) }),
],
LABELS,
'valhalla',
)
expect(out.map(o => o.otherEngine)).toEqual([false, false, true])
// The quickest is elected among Valhalla's figures, so the OSRM one cannot take it.
expect(out[1].note).toBe('Fastest')
expect(out[0].slowerThanQuickest).toBe(378)
expect(out[2].slowerThanQuickest).toBe(0)
})
it('FE-ALTOVL-020: on a leg a plugin prices, every router offer is read as another engine', () => {
const out = buildAlternativeOverlays(
[
alt({ duration: 9_000, coordinates: line(20, 0), current: true, engine: 'plugin' }),
alt({ duration: 7_200, coordinates: line(20, 1) }),
],
LABELS,
'plugin',
)
expect(out.map(o => o.otherEngine)).toEqual([false, true])
expect(out[1].note).toBe('')
expect(out.every(o => o.slowerThanQuickest === 0)).toBe(true)
})
}) })
describe('alternativesPhase', () => { describe('alternativesPhase', () => {
@@ -253,5 +303,60 @@ describe('alternativeSubline', () => {
// The caller words the difference; the figure is the same short duration the map prints. // The caller words the difference; the figure is the same short duration the map prints.
expect(alternativeSubline(overlay({ note: '', slowerThanQuickest: 1800 }), slower)) expect(alternativeSubline(overlay({ note: '', slowerThanQuickest: 1800 }), slower))
.toBe(`${formatDurationShort(1800)} slower`) .toBe(`${formatDurationShort(1800)} slower`)
// Another engine's road has no difference to print, only its own time.
expect(alternativeSubline(overlay({ note: '', otherEngine: true, label: '2 h', slowerThanQuickest: 0 }), slower))
.toBe('2 h')
})
})
describe('otherEngineNote', () => {
it('FE-ALTOVL-022: a route another engine timed is named after that engine, one the rail timed not at all', () => {
// On a plugin leg the offers are OSRM's. They used to be called the avoidance
// router's, an engine that had nothing to do with them.
const plugin = buildAlternativeOverlays(
[
alt({ coordinates: line(20, 0), current: true, engine: 'plugin' }),
alt({ coordinates: line(20, 1) }),
alt({ coordinates: line(20, 2), engine: 'valhalla', avoids: 'motorway' }),
],
LABELS,
'plugin',
)
expect(plugin.map(o => o.engine)).toEqual(['plugin', 'osrm', 'valhalla'])
expect(plugin.map(otherEngineNote)).toEqual([null, 'roadtrip.alt.otherEngineStandard', 'roadtrip.alt.otherEngine'])
// On a trip that avoids nothing, the avoidance router's offer is the other one.
const plain = buildAlternativeOverlays(
[alt({ coordinates: line(20, 0), current: true }), alt({ coordinates: line(20, 1), engine: 'valhalla' })],
LABELS,
)
expect(plain.map(otherEngineNote)).toEqual([null, 'roadtrip.alt.otherEngine'])
})
it('FE-ALTOVL-023: a line OSRM drew in the avoidance router’s place heads the list as the other engine’s', () => {
const out = buildAlternativeOverlays(
[
alt({ coordinates: line(20, 0), current: true, engine: 'osrm' }),
alt({ coordinates: line(20, 1), engine: 'valhalla' }),
],
LABELS,
'valhalla',
)
expect(out.map(otherEngineNote)).toEqual(['roadtrip.alt.otherEngineStandard', null])
// Nothing is measured against it: the offer is the quickest the rail's engine timed.
expect(out[1].note).toBe('Fastest')
expect(out.every(o => o.slowerThanQuickest === 0)).toBe(true)
// An engine no offer comes from has nothing to say.
expect(otherEngineNote({ otherEngine: true, engine: 'plugin' })).toBeNull()
})
})
describe('alternativesBusy', () => {
it('FE-ALTOVL-021: a picker is busy exactly while one of its offers is being checked', () => {
expect(alternativesBusy(null)).toBe(false)
expect(alternativesBusy(undefined)).toBe(false)
expect(alternativesBusy({ proving: null })).toBe(false)
// Index 0 is a real offer, not "nothing".
expect(alternativesBusy({ proving: 0 })).toBe(true)
expect(alternativesBusy({ proving: 2 })).toBe(true)
}) })
}) })
@@ -1,6 +1,6 @@
import { formatDurationShort } from './roadtripModel' import { formatDurationShort } from './roadtripModel'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors' import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator' import type { RouteAlternative, RouteEngine } from '../Map/RouteCalculator'
import type { LegAlternatives } from './useRouteAlternatives' import type { LegAlternatives } from './useRouteAlternatives'
/** One offered route, ready to draw: the line, its colour, and where its label sits. */ /** One offered route, ready to draw: the line, its colour, and where its label sits. */
@@ -24,6 +24,8 @@ export interface AlternativeOverlay {
* `slowerThanQuickest` stays zero. * `slowerThanQuickest` stays zero.
*/ */
otherEngine: boolean otherEngine: boolean
/** The engine that priced this route, which names the other engine when it is one. */
engine: RouteEngine
labelBg: string labelBg: string
/** Where to hang the label — a point on this route and on no other. */ /** Where to hang the label — a point on this route and on no other. */
at: { lat: number; lng: number } at: { lat: number; lng: number }
@@ -88,29 +90,35 @@ function mostDistinctPoint(
* Each label hangs where its own route is furthest from all the others, so a label always * Each label hangs where its own route is furthest from all the others, so a label always
* sits on a stretch only that route uses. Two routes that share their first and last * sits on a stretch only that route uses. Two routes that share their first and last
* thirds still get their labels on the middle third, where they actually differ. * thirds still get their labels on the middle third, where they actually differ.
*
* `railEngine` is the engine the rail drives this leg with. Only routes that engine
* priced can be read against the road being driven; every other one is marked as the
* other engine's and takes part in no comparison.
*/ */
export function buildAlternativeOverlays( export function buildAlternativeOverlays(
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined, routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string }, labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
railEngine: RouteEngine = 'osrm',
): AlternativeOverlay[] { ): AlternativeOverlay[] {
if (!routes?.length) return [] if (!routes?.length) return []
// A single answer is not a choice; drawing it would just double the route already there. // A single answer is not a choice; drawing it would just double the route already there.
if (routes.length < 2) return [] if (routes.length < 2) return []
// The quickest of what came back, which is what Apple calls out — not necessarily the // The quickest of what came back, which is what Apple calls out — not necessarily the
// first entry, since the road currently driven is put at the top when there is one. // first entry, since the road currently driven is put at the top.
// //
// Only among the routes one engine priced. The avoidance offer on a default install // Only among the routes the rail's own engine priced, the current road included. An
// comes from the second engine, whose speed model differs by up to a seventh either // offer from another engine runs on a speed model that differs by up to a seventh
// way depending on the region, so letting it into this election decides which road is // either way depending on the region, so letting it into this election decides which
// blue and how much slower every other road is called on nothing but that gap. On a // road is fastest and how much slower every other road is called on nothing but that
// Spanish motorway leg that is enough to crown a toll-free B-road detour and label // gap. On a Spanish motorway leg that is enough to crown a toll-free B-road detour and
// the motorway somebody is actually driving as the slower way round. // label the motorway somebody is actually driving as the slower way round.
// //
// Index 0 is the fallback rather than -1: the first entry is the road being driven // Index 0 is the fallback rather than -1: the first entry is the road being driven
// when there is one and the router's own pick otherwise, so if a list ever held // when there is one and the router's own pick otherwise, so if a list ever held
// nothing but second-engine routes, that is still the one to call primary. // nothing the rail's engine priced, that is still the one to call primary.
const comparable = routes.map((r, i) => (r.engine ? -1 : i)).filter(i => i >= 0) const engineOf = (route: RouteAlternative): RouteEngine => route.engine ?? 'osrm'
const comparable = routes.map((r, i) => (engineOf(r) === railEngine ? i : -1)).filter(i => i >= 0)
const quickest = comparable.length const quickest = comparable.length
? comparable.reduce((best, i) => (routes[i].duration < routes[best].duration ? i : best), comparable[0]) ? comparable.reduce((best, i) => (routes[i].duration < routes[best].duration ? i : best), comparable[0])
: 0 : 0
@@ -119,10 +127,11 @@ export function buildAlternativeOverlays(
return routes.map((route, index) => { return routes.map((route, index) => {
const others = routes.filter((_, i) => i !== index).map(r => r.coordinates) const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
const at = mostDistinctPoint(route.coordinates, others) const at = mostDistinctPoint(route.coordinates, others)
// Blue is the road you are on: the one currently driven, or the router's own pick // Blue is the road you are on, which the picker always lists as the current one,
// when nothing has been bent. Everything else is the pale blue of an offer. // however slow it is. Only a list without it (none is built that way any more) falls
const primary = route.current || (!anyCurrent && index === quickest) // back to the quickest. Everything else is the pale blue of an offer.
const otherEngine = !!route.engine const primary = anyCurrent ? !!route.current : index === quickest
const otherEngine = engineOf(route) !== railEngine
return { return {
index, index,
coordinates: route.coordinates, coordinates: route.coordinates,
@@ -146,10 +155,11 @@ export function buildAlternativeOverlays(
// that is the only thing to measure against. The bar reads a zero as "no // that is the only thing to measure against. The bar reads a zero as "no
// difference worth printing" and falls back to naming what the road is, which // difference worth printing" and falls back to naming what the road is, which
// for these is always the class left out of it. // for these is always the class left out of it.
slowerThanQuickest: otherEngine || !!routes[quickest].engine slowerThanQuickest: otherEngine || engineOf(routes[quickest]) !== railEngine
? 0 ? 0
: Math.max(0, Math.round(route.duration - routes[quickest].duration)), : Math.max(0, Math.round(route.duration - routes[quickest].duration)),
otherEngine, otherEngine,
engine: engineOf(route),
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG, labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
at: at ?? { lat: 0, lng: 0 }, at: at ?? { lat: 0, lng: 0 },
} }
@@ -189,5 +199,33 @@ export function alternativesPhase(
* instead of each keeping its own. * instead of each keeping its own.
*/ */
export function alternativeSubline(alt: AlternativeOverlay, slower: (time: string) => string): string { export function alternativeSubline(alt: AlternativeOverlay, slower: (time: string) => string): string {
return alt.note || slower(formatDurationShort(alt.slowerThanQuickest)) if (alt.note) return alt.note
// Another engine's road has no difference to the quickest, only its own time: a "0 min
// slower" under it would read as a tie that nobody measured.
if (alt.otherEngine) return alt.label
return slower(formatDurationShort(alt.slowerThanQuickest))
}
/**
* What a route another engine timed says about it, as a translation key: which engine it
* was. Null for a route the rail's own engine timed.
*
* Both bars used to call every such route the avoidance router's. A leg a route provider
* plugin drives is offered OSRM's ways, and a leg OSRM drew while the avoidance router did
* not answer heads its list with OSRM's line, so the note named an engine that had nothing
* to do with either. One reading for the desk and the phone.
*/
export function otherEngineNote(alt: Pick<AlternativeOverlay, 'otherEngine' | 'engine'>): string | null {
if (!alt.otherEngine) return null
if (alt.engine === 'valhalla') return 'roadtrip.alt.otherEngine'
// No offer comes from a plugin: only the rail's own line on a plugin leg is one's.
return alt.engine === 'osrm' ? 'roadtrip.alt.otherEngineStandard' : null
}
/**
* Whether a choice is being checked and saved, so neither bar takes another one meanwhile.
* One reading for the desk and the phone, which both stand still while it runs.
*/
export function alternativesBusy(open: Pick<LegAlternatives, 'proving'> | null | undefined): boolean {
return open?.proving != null
} }
@@ -0,0 +1,218 @@
import { describe, it, expect, vi } from 'vitest'
import { MAX_PINS, pinAlternative, railDriveOn, railLegAt, refusalHint } from './alternativePins'
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { OfferedRoute } from './useRouteAlternatives'
/**
* FE-ALTPIN-001..013: proving a choice with the rail's own router before it is saved.
*
* A choice used to be one via at the point where the offer strayed furthest, written
* without asking whether the router then drove the offer. On a ferry it did not (OSRM
* pulls the point to the pier and drives through Calais), and on a way weighed away from
* motorways it kept the motorway after the one point. These cases pin down how the pins
* are found, how many, in which order, and when there is no verdict at all.
*/
/** The road being driven, and a detour that leaves it twice before rejoining. */
const RAIL: [number, number][] = [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]]
const DETOUR: [number, number][] = [[53.55, 9.99], [53.4, 11.5], [53.3, 12.8], [52.52, 13.4]]
const offer = (over: Partial<OfferedRoute> = {}): OfferedRoute =>
({ coordinates: DETOUR, distance: 310_000, duration: 11_400, divergence: null, ...over })
const current = { coordinates: RAIL, distance: 290_000, duration: 10_800 }
const answer = (coordinates: [number, number][], over: Partial<RailLegRoute> = {}): RailLegRoute =>
({ coordinates, distance: 300_000, duration: 11_000, ...over })
const signal = () => new AbortController().signal
type Route = RailLegRouter['route']
describe('pinAlternative', () => {
it('FE-ALTPIN-001: the router own preference is asked with no pin at all', async () => {
const route = vi.fn<Route>(async () => answer(DETOUR))
const proof = await pinAlternative({ offer: offer({ direct: true }), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(1)
expect(route.mock.calls[0][0]).toEqual([])
expect(proof).toMatchObject({ held: true, pins: [], fellBack: false })
})
it('FE-ALTPIN-002: any other offer starts from the point where it leaves the driven road furthest', async () => {
const route = vi.fn<Route>(async () => answer(DETOUR))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(1)
expect(proof).toMatchObject({ held: true, pins: [{ lat: 53.3, lng: 12.8 }] })
})
it('FE-ALTPIN-003: an answer that is not the offer adds the point it missed, in the order the offer drives', async () => {
// The first answer passes the pin and still takes the old road for its first half.
const route = vi.fn<Route>()
.mockResolvedValueOnce(answer([[53.55, 9.99], [53.0, 11.5], [53.3, 12.8], [52.52, 13.4]]))
.mockResolvedValueOnce(answer(DETOUR))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(2)
// Driven through backwards the leg would double back between them.
expect(route.mock.calls[1][0]).toEqual([{ lat: 53.4, lng: 11.5 }, { lat: 53.3, lng: 12.8 }])
expect(proof.pins).toEqual([{ lat: 53.4, lng: 11.5 }, { lat: 53.3, lng: 12.8 }])
expect(proof.held).toBe(true)
})
it('FE-ALTPIN-004: no more than three pins are tried before the offer counts as not held', async () => {
// A long detour the router leaves somewhere new on every answer.
const long: [number, number][] = [[50, 0], [50.5, 1], [51, 2], [51.5, 3], [52, 4], [52.5, 5], [52, 6]]
const answers: [number, number][][] = [
[[50, 0], [50, 1], [51, 2], [51.5, 3], [52, 4], [52.5, 5], [52, 6]],
[[50, 0], [50.5, 1], [51, 2], [51, 3], [52, 4], [52.5, 5], [52, 6]],
[[50, 0], [50.5, 1], [51, 2], [51.5, 3], [52, 4], [52, 5], [52, 6]],
]
const route = vi.fn<Route>()
for (const line of answers) route.mockResolvedValueOnce(answer(line))
const proof = await pinAlternative({ offer: offer({ coordinates: long }), current: { ...current, coordinates: [[50, 0], [52, 6]] }, route, signal: signal() })
expect(MAX_PINS).toBe(3)
expect(route).toHaveBeenCalledTimes(3)
expect(route.mock.calls[2][0]).toHaveLength(3)
expect(proof).toMatchObject({ held: false, pins: [], fellBack: false })
expect(proof.last.coordinates).toEqual(answers[2])
})
it('FE-ALTPIN-005: an answer that misses the same point again ends the check rather than repeating it', async () => {
// A point OSRM pulls off a ferry to the pier: pinned again, the same road comes back.
const route = vi.fn<Route>(async () => answer(RAIL))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(route).toHaveBeenCalledTimes(1)
expect(proof).toMatchObject({ held: false, fellBack: false })
expect(proof.last.coordinates).toEqual(RAIL)
})
it('FE-ALTPIN-006: an answer from the stand-in engine gives no verdict on the road', async () => {
// It would prove the offer to an engine that will not be the one drawing the leg.
const route = vi.fn<Route>(async () => answer(DETOUR, { fellBack: true }))
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
expect(proof).toMatchObject({ held: false, fellBack: true, pins: [] })
})
it('FE-ALTPIN-007: with no road to measure against, the check starts unpinned', async () => {
const route = vi.fn<Route>(async () => answer(DETOUR))
await pinAlternative({ offer: offer(), current: undefined, route, signal: signal() })
expect(route.mock.calls[0][0]).toEqual([])
})
it('FE-ALTPIN-008: an abandoned check asks the router nothing more', async () => {
const controller = new AbortController()
const route = vi.fn<Route>(async () => {
controller.abort()
return answer([[53.55, 9.99], [53.0, 11.5], [53.3, 12.8], [52.52, 13.4]])
})
await expect(pinAlternative({ offer: offer(), current, route, signal: controller.signal }))
.rejects.toMatchObject({ name: 'AbortError' })
expect(route).toHaveBeenCalledTimes(1)
})
})
describe('railLegAt', () => {
const stop = (assignmentId: number, ownerDayId: number, ownerIndex: number, over: Partial<RoadtripStop> = {}) =>
({ assignmentId, ownerDayId, ownerIndex, lat: 50, lng: 10, ...over }) as RoadtripStop
const day = (dayId: number, stops: RoadtripStop[]) => ({ dayId, stops }) as RoadtripDay
it('FE-ALTPIN-009: finds the leg leaving the stop filed at the anchor, inside a card or across to the next', () => {
const days = [
day(1, [stop(10, 1, 0), stop(11, 1, 1)]),
// A card that opens on a stop stored the day before, and a night marker standing on
// the same position as a real stop.
day(2, [stop(12, 1, 2), stop(12, 1, 2, { automaticNight: { phase: 'start' } as RoadtripStop['automaticNight'] }), stop(20, 2, 0)]),
]
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toMatchObject({ from: { assignmentId: 11 }, to: { assignmentId: 12 } })
expect(railLegAt(days, { dayId: 1, afterIndex: 2 })).toMatchObject({ from: { assignmentId: 12 }, to: { assignmentId: 20 } })
})
it('FE-ALTPIN-010: a terminal borrowing the index is no answer, and an anchor with nothing after it is none', () => {
const terminal = stop(-99, 1, 0, { carrier: { role: 'departure' } as RoadtripStop['carrier'] })
const days = [day(1, [terminal, stop(10, 1, 0), stop(11, 1, 1)])]
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toBeNull()
expect(railLegAt(days, { dayId: 3, afterIndex: 0 })).toBeNull()
})
it('FE-ALTPIN-013: neither is the hotel a day sets out from, so a choice on the first leg is still that leg', () => {
// The morning's hotel borrows the first stop's index. Answering for it, the leg read
// back was hotel to first stop, and every choice on leg 0 came back as changed.
const bookend = (id: number, ownerIndex: number, phase: 'morning' | 'evening') => stop(id, 1, ownerIndex, {
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
})
const days = [day(1, [bookend(-6_000_000_002, 0, 'morning'), stop(10, 1, 0), stop(11, 1, 1), bookend(-6_000_000_003, 2, 'evening')])]
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
// Behind the last stop the rail drives to tonight's hotel, and that is what it reports.
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toMatchObject({ from: { assignmentId: 11 }, to: { assignmentId: -6_000_000_003 } })
})
})
describe('railDriveOn', () => {
const stop = (assignmentId: number, ownerDayId: number, ownerIndex: number) =>
({ assignmentId, ownerDayId, ownerIndex, lat: 50, lng: 10 }) as RoadtripStop
const seg = (distance: number) => ({ distance, duration: distance / 20 }) as RoadtripDay['legs'][number]
const LEG_LINE: [number, number][] = [[50, 10], [50.2, 10.4]]
const DRIVE_IN: [number, number][] = [[51, 9], [50, 10]]
const card = (over: Partial<RoadtripDay> = {}) => ({
dayId: 2,
stops: [stop(20, 2, 0), stop(21, 2, 1)],
legs: [seg(40_000)],
legLines: [LEG_LINE],
arrivingFrom: stop(12, 1, 2),
arrivingLeg: seg(90_000),
arrivingLine: DRIVE_IN,
...over,
}) as RoadtripDay
it('FE-ALTPIN-011: a leg is read off its index, the drive in off the stop the day before ended on', () => {
expect(railDriveOn(card(), { kind: 'leg', index: 0 })).toMatchObject({
from: { assignmentId: 20 }, to: { assignmentId: 21 }, seg: { distance: 40_000 }, line: LEG_LINE,
})
expect(railDriveOn(card(), { kind: 'arriving' })).toMatchObject({
from: { assignmentId: 12, ownerDayId: 1, ownerIndex: 2 }, to: { assignmentId: 20 }, seg: { distance: 90_000 }, line: DRIVE_IN,
})
// Nothing past the last stop, no drive in on a card nothing joins, and no road yet.
expect(railDriveOn(card(), { kind: 'leg', index: 1 })).toBeNull()
expect(railDriveOn(card({ arrivingFrom: undefined, arrivingLeg: undefined }), { kind: 'arriving' })).toBeNull()
expect(railDriveOn(card({ legs: [] }), { kind: 'leg', index: 0 })).toBeNull()
// A line the card does not have yet is no reason to refuse the drive.
expect(railDriveOn(card({ legLines: undefined }), { kind: 'leg', index: 0 })?.line).toBeUndefined()
})
})
describe('refusalHint', () => {
/** Echoes the key with its values, so a case reads which sentence was picked and with what. */
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
it('FE-ALTPIN-012: a refused way says what would get it driven: a ferry booking, or the class left out avoided', () => {
// The ferry the router would not board wins: the crossing is a booking, whatever else
// the way leaves out.
expect(refusalHint({ hasFerry: true, avoids: 'motorway' }, { hasFerry: false }, t)).toBe('roadtrip.alt.ferryNotHeld')
// A way without the motorway on a trip that does not avoid it is held for a few pins
// at most; ticking the class is what drives it.
expect(refusalHint({ avoids: 'motorway' }, { hasFerry: false }, t))
.toBe('roadtrip.alt.avoidNotHeld(class=roadtrip.avoid.motorway,setting=roadtrip.avoid.section)')
expect(refusalHint({ avoids: 'toll' }, {}, t))
.toBe('roadtrip.alt.avoidNotHeld(class=roadtrip.avoid.toll,setting=roadtrip.avoid.section)')
// A plain way, or a ferry the router did board, has no way out to offer.
expect(refusalHint({}, {}, t)).toBeNull()
expect(refusalHint({ hasFerry: true }, { hasFerry: true }, t)).toBeNull()
})
})
@@ -0,0 +1,147 @@
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives'
/**
* The most points one choice may pin to the road.
*
* Every pin is one more request to a host that allows one a second, and one more handle on
* the map somebody has to understand later. A way that three points cannot hold is not
* held by the router at all: a ferry OSRM will not board, or a class the day is not
* weighed against.
*/
export const MAX_PINS = 3
/** A point a choice is held by, in the order the chosen road drives through them. */
export interface Pin {
lat: number
lng: number
}
/** Whether the rail's router drives an offer, and the pins that make it do so. */
export interface PinProof {
/** True when the router drove the offer with `pins` in place. */
held: boolean
/** The pins that held it, in the order the offer drives through them. Empty otherwise. */
pins: Pin[]
/** True when the last answer came from a stand-in engine, which proves nothing either way. */
fellBack: boolean
/** The router's answer to the last attempt. */
last: RailLegRoute
}
/**
* The fewest points that make the rail's own router drive `offer`, found by asking it.
*
* A choice used to be stored as one via at the point where the offer strayed furthest,
* and nobody asked whether the router then drove the offer. Often it did not. A point on
* a ferry is pulled to the nearest pier by OSRM, which never boards one, so Amsterdam to
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. An
* offer weighed away from motorways by the second engine is held by one point for a few
* kilometres, and the motorway takes the rest. Either way the choice looked saved and the
* drive did something else.
*
* So the check is the router itself. The router's own preference starts with no pin; any
* other offer with the point furthest from the road being driven. After each answer that
* is not the offer, the point of the offer furthest from that answer joins in, placed in
* the order the offer drives through them, until the answer is the offer or `maxPins` is
* reached. The spacing between the requests is the router's own (`RailLegRouter.route`).
*
* An answer from a stand-in engine proves nothing about the one that will draw the leg,
* so it ends the check without a verdict on the road.
*/
export async function pinAlternative({
offer,
current,
route,
signal,
maxPins = MAX_PINS,
}: {
offer: OfferedRoute
/** The road the rail drives now; the first pin is where the offer leaves it furthest. */
current: RoadLine | undefined
route: RailLegRouter['route']
signal: AbortSignal
maxPins?: number
}): Promise<PinProof> {
const placed: { index: number; lat: number; lng: number }[] = []
if (!offer.direct) {
const far = furthestFrom(offer.coordinates, current?.coordinates ?? [])
if (far) placed.push(far)
}
for (;;) {
if (signal.aborted) throw new DOMException('The check was abandoned', 'AbortError')
const last = await route(placed.map(({ lat, lng }) => ({ lat, lng })), signal)
if (last.fellBack) return { held: false, pins: [], fellBack: true, last }
if (sameRoad(last, offer)) return { held: true, pins: placed.map(({ lat, lng }) => ({ lat, lng })), fellBack: false, last }
if (placed.length >= maxPins) return { held: false, pins: [], fellBack: false, last }
const far = furthestFrom(offer.coordinates, last.coordinates)
// The same point again would ask the same question and earn the same answer.
if (!far || placed.some(p => p.index === far.index)) return { held: false, pins: [], fellBack: false, last }
placed.push(far)
placed.sort((a, b) => a.index - b.index)
}
}
/**
* The way out of a refusal, where there is one, worded by `t`; null where there is none.
*
* A crossing by ferry is a booking, and the drive follows a booked ferry from terminal to
* terminal. A way offered because it leaves a class of road out is driven once the trip
* avoids that class: without it the router holds such a way only as far as a few pins
* reach, and on a long leg takes the class back between them, so the choice was refused
* after seconds of checking with no word on what would work.
*/
export function refusalHint(
offer: Pick<OfferedRoute, 'hasFerry' | 'avoids'>,
last: Pick<RailLegRoute, 'hasFerry'>,
t: (key: string, params?: Record<string, string | number>) => string,
): string | null {
if (offer.hasFerry && !last.hasFerry) return t('roadtrip.alt.ferryNotHeld')
if (!offer.avoids) return null
return t('roadtrip.alt.avoidNotHeld', { class: t(`roadtrip.avoid.${offer.avoids}`), setting: t('roadtrip.avoid.section') })
}
/**
* The leg the rail drives from the stop filed at `anchor` now, or null when no stop sits
* there any more.
*
* Read across every day in order, so the stop after it is found whether the leg runs
* inside one card or across to the next. The caller hands in the days with a single stop
* too: they draw no card, but the drive into the day after one leaves from its stop. The
* markers an automatic night puts on the chain stand on or between stops without being
* one, and a terminal or a booked night at a day's edge borrows the index of a stored stop
* to be seated by, so none of them can answer for the anchor.
*/
export function railLegAt(days: readonly Pick<RoadtripDay, 'stops'>[], anchor: ViaAnchor): { from: RoadtripStop; to: RoadtripStop } | null {
const chain = days.flatMap(day => day.stops).filter(stop => !stop.automaticNight)
const at = chain.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
const to = at >= 0 ? chain[at + 1] : undefined
return to ? { from: chain[at], to } : null
}
/** One drive on a card as the rail has it: its two stops, its road and the line it is drawn on. */
export interface RailDriveOnCard {
from: RoadtripStop
to: RoadtripStop
seg: RouteSegment
line: [number, number][] | undefined
}
/**
* The drive `drive` names on `day`, or null while the card has no such drive or no road
* for it yet.
*
* A leg runs from stop `index` to the next one. The drive arriving at the head of a
* connected card has no index on it: it leaves the last stop of the day before, which the
* card keeps as `arrivingFrom`, and ends at the card's first stop. Read here once, so the
* picker is handed the same two stops, figures and line whichever of the two it opens on.
*/
export function railDriveOn(day: RoadtripDay, drive: RailDrive): RailDriveOnCard | null {
const found = drive.kind === 'arriving'
? { from: day.arrivingFrom, to: day.stops[0], seg: day.arrivingLeg, line: day.arrivingLine }
: { from: day.stops[drive.index], to: day.stops[drive.index + 1], seg: day.legs[drive.index], line: day.legLines?.[drive.index] }
const { from, to, seg, line } = found
return from && to && seg ? { from, to, seg, line } : null
}
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest'
import { missedRide, rideDuration, rideReading, terminalReading } from './carrierRide'
import type { CarrierTerminal, RoadtripStop, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
// FE-CARRIER-RIDE-001 to FE-CARRIER-RIDE-009
/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */
const t = (key: string, params?: Record<string, string | number | null>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
/**
* One end of LH 2078 as the routing round seats it: the departure pinned an hour ahead of
* the timetable for the check-in and left at the timetable's minute, the arrival pinned at
* its own. Named the way every airport writer names an endpoint.
*/
function terminal(role: CarrierTerminal['role'], over: Partial<CarrierTerminal> = {}, stop: Partial<RoadtripStop> = {}): RoadtripStop {
const departs = role === 'departure'
const carrier: CarrierTerminal = {
reservationId: 78,
type: 'flight',
role,
title: 'LH 2078 HAM-MUC (ohne Endpunkte)',
code: departs ? 'HAM' : 'MUC',
at: departs ? '15:15' : '17:20',
...over,
}
return {
assignmentId: departs ? -780 : -781,
ownerDayId: 3,
ownerIndex: 1,
placeId: -78,
name: departs ? 'Hamburg (HAM)' : 'Munich (MUC)',
lat: departs ? 53.63 : 48.35,
lng: departs ? 9.99 : 11.78,
time: departs ? '14:15' : '17:20',
dwellMinutes: departs ? 60 : 0,
legMode: departs ? carrier.type : null,
incomingLegMode: departs ? null : carrier.type,
stopType: null,
carrier,
...stop,
}
}
const FLIGHT: RouteSegment = {
mid: [50, 10], from: [53.63, 9.99], to: [48.35, 11.78],
distance: 0, duration: 7500, walkingText: '', drivingText: '', distanceText: '', durationText: '2 h 5 min',
mode: 'flight',
}
const late = (minutes: number): ScheduleWarning => ({ index: 1, code: 'late', minutes })
const read = (warnings: ScheduleWarning[] = [], is12h = false, over: Partial<CarrierTerminal> = {}) =>
rideReading({
departure: terminal('departure', over),
arrival: terminal('arrival', over),
entries: [
{ arrival: '14:15', departure: '15:15', anchored: true, dayOffset: 0 },
{ arrival: '17:20', departure: '17:20', anchored: true, dayOffset: 0 },
],
warnings,
seg: FLIGHT,
}, t, is12h)
describe('a ride as its block reads it', () => {
it('FE-CARRIER-RIDE-001: every fact once, the title as typed, the code off the place, and the check-in with its hour', () => {
const ride = read()
expect(ride.title).toBe('LH 2078 HAM-MUC (ohne Endpunkte)')
expect(ride.duration).toBe('2 h 5 min')
expect(ride.from).toEqual({
code: 'HAM',
place: 'Hamburg',
chip: 'HAM',
placeLine: 'Hamburg',
clock: '15:15',
dayOffset: 0,
label: 'roadtrip.ride.departureFlight(time=15:15)',
})
expect(ride.to).toMatchObject({ chip: 'MUC', placeLine: 'Munich', clock: '17:20', label: 'roadtrip.ride.arrival(time=17:20)' })
expect(ride.checkIn).toEqual({
state: 'ok',
lead: 'roadtrip.ride.checkIn',
value: '14:15',
hint: 'roadtrip.ride.hintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15)',
})
})
it('FE-CARRIER-RIDE-002: reached after the check-in but before take-off is tight, and says by how much', () => {
const checkIn = read([late(25)]).checkIn!
expect(checkIn.state).toBe('tight')
expect(checkIn.lead).toBe('roadtrip.ride.checkIn')
expect(checkIn.value).toBe('roadtrip.ride.lateBy(time=25 min)')
expect(checkIn.hint).toBe(
'roadtrip.ride.lateHintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15,place=Hamburg,time=14:40)',
)
// Reaching the gate as the plane leaves is still a catch.
expect(read([late(60)]).checkIn!.state).toBe('tight')
})
it('FE-CARRIER-RIDE-003: reached after take-off is missed, and says when the drive gets there instead of a delta', () => {
const checkIn = read([late(579)]).checkIn!
expect(checkIn.state).toBe('missed')
expect(checkIn.lead).toBe('roadtrip.ride.missed')
expect(checkIn.value).toBe('roadtrip.ride.reachedAt(time=23:54)')
expect(checkIn.hint).toContain('place=Hamburg,time=23:54')
// Past midnight the clock carries its day the way the rail's arrivals do, in the reader's clock.
expect(read([late(12 * 60 + 15)], true).checkIn!.value).toBe('roadtrip.ride.reachedAt(time=2:30 AM+1)')
// Findings that are not about being late leave the check-in alone.
expect(read([{ index: 1, code: 'leg', overMinutes: 30 }]).checkIn!.state).toBe('ok')
})
it('FE-CARRIER-RIDE-004: a train or a bus is boarded, a ferry or a cruise sails, and only a flight has its own departure word', () => {
const train = read([], false, { type: 'train', code: null })
expect(train.checkIn!.lead).toBe('roadtrip.ride.boarding')
expect(train.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintBoarding\(/)
expect(train.from.label).toBe('roadtrip.ride.departure(time=15:15)')
expect(read([late(25)], false, { type: 'bus' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.lateHintBoarding\(/)
const ferry = read([], false, { type: 'ferry' })
expect(ferry.checkIn!.lead).toBe('roadtrip.ride.checkIn')
expect(ferry.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/)
expect(read([], false, { type: 'cruise' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/)
})
it('FE-CARRIER-RIDE-005: a terminal without a code puts its name in the chip, and one without a timetable names no clock and no check-in', () => {
const station = rideReading({
departure: terminal('departure', { type: 'train', code: null, at: null }, { name: 'Hamburg Hbf', time: null }),
arrival: terminal('arrival', { type: 'train', code: null, at: null }, { name: 'München Hbf', time: null }),
entries: [undefined, { arrival: '07:20', departure: '07:20', anchored: false, dayOffset: 1 }],
warnings: [],
seg: undefined,
}, t, false)
expect(station.from).toMatchObject({ code: null, chip: 'Hamburg Hbf', placeLine: null, clock: null, label: null, dayOffset: 0 })
expect(station.to).toMatchObject({ chip: 'München Hbf', dayOffset: 1 })
expect(station.duration).toBeNull()
expect(station.checkIn).toBeNull()
})
})
describe('a terminal on a row of its own', () => {
it('FE-CARRIER-RIDE-006: a desk says which one it is and has no check-in; a lone departure has one, a lone arrival has none', () => {
const pickup = terminalReading(
terminal('pickup', { type: 'car', title: 'Sixt Hamburg', code: null, at: '09:00' }, { name: 'Sixt Hauptbahnhof', time: '09:00' }),
undefined,
[],
t,
false,
)
expect(pickup.desk).toBe('reservations.span.pickup')
expect(pickup.checkIn).toBeNull()
expect(pickup.end).toMatchObject({ place: 'Sixt Hauptbahnhof', clock: '09:00', label: 'roadtrip.ride.pickup(time=09:00)' })
const leaving = terminalReading(terminal('departure'), undefined, [late(579)], t, false)
expect(leaving.desk).toBeNull()
expect(leaving.checkIn!.state).toBe('missed')
expect(terminalReading(terminal('arrival'), undefined, [late(30)], t, false).checkIn).toBeNull()
})
})
describe('the day a ride is missed on', () => {
const day = (warnings: ScheduleWarning[], type = 'flight') => ({
stops: [
{ ...terminal('pickup', { type: 'car', code: null }), carrier: undefined, name: 'Hotel Atlantic' },
terminal('departure', { type }),
terminal('arrival', { type }),
],
schedule: { entries: [], warnings },
})
it('FE-CARRIER-RIDE-007: names the kind of ride in the header and keeps the sentence for its tooltip', () => {
expect(missedRide(day([late(579)]), t, false)).toEqual({
label: 'roadtrip.day.rideMissed.flight',
hint: expect.stringContaining('time=23:54'),
})
expect(missedRide(day([late(579)], 'ferry'), t, false)!.label).toBe('roadtrip.day.rideMissed.ferry')
})
it('FE-CARRIER-RIDE-008: a ride caught, late or not, and a finding at another stop say nothing in the header', () => {
expect(missedRide(day([]), t, false)).toBeNull()
expect(missedRide(day([late(25)]), t, false)).toBeNull()
expect(missedRide(day([{ index: 2, code: 'late', minutes: 600 }]), t, false)).toBeNull()
})
})
describe('rideDuration', () => {
it('FE-CARRIER-RIDE-009: the leg\'s own text, else its seconds, else nothing', () => {
expect(rideDuration(FLIGHT)).toBe('2 h 5 min')
expect(rideDuration({ ...FLIGHT, durationText: '' })).toBe('2 h 5 min')
expect(rideDuration({ ...FLIGHT, durationText: '', duration: 0 })).toBeNull()
expect(rideDuration(undefined)).toBeNull()
})
})
+198 -18
View File
@@ -1,15 +1,18 @@
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react' import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
import { formatDurationShort } from './roadtripModel' import { formatClock, formatDurationShort, parseClock, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters' import { formatClockTime } from '../../utils/formatters'
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip' import { stripAirportCode } from '../../utils/flightLegs'
import type { TranslationFn } from '../../types' import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { CARRIER_TYPES, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { Reservation, TranslationFn } from '../../types'
/** /**
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in * How a ride and its two terminals, and a hire car's two desks, read on the rail and in
* the phone chain (#2428). * the phone chain (#2428), and which of the bookings under a stop open there.
* *
* One module for both shells, because the two would otherwise carry the same icon table * 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 same reading of the timetable against the drive and the same sentences each, and the
* duplication budget does not stretch to that. The shells only draw what comes out of here.
* The icons are the booking panel's for the same types, so a flight looks like a flight * The icons are the booking panel's for the same types, so a flight looks like a flight
* wherever the trip shows it. * wherever the trip shows it.
*/ */
@@ -27,21 +30,198 @@ export function carrierIcon(type: string): LucideIcon {
} }
/** /**
* The line under a terminal's name: the timetable's clock at it, in the reader's own * How long the ride takes on the timetable: the leg's own text, or its seconds when the
* clock format. Nothing when the booking names no time. * text is not in yet. Null for a ride the timetable gives no minutes for.
*/ */
export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null { export function rideDuration(seg: RouteSegment | undefined): string | null {
if (!carrier.at) return null const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
const time = formatClockTime(carrier.at, is12h) return seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : null)
return t(`roadtrip.ride.${carrier.role}`, { time }) }
/** One end of a ride, or a hire car's desk, as a row or the route strip of a block reads it. */
export interface RideEnd {
/** IATA or station code, when the booking carries one. */
code: string | null
/** The terminal's name without the code the airport forms append to it. */
place: string
/** What the route strip's chip holds: the code, or the name of a terminal without one. */
chip: string
/** The name under the chip, or null when the chip already is the name. */
placeLine: string | null
/** The timetable's clock here, in the reader's format. Null when the booking names none. */
clock: string | null
/** Days past the card's own that the clock falls on, for the timetable's "+1". */
dayOffset: number
/** The clock in words, "Departure 15:15", for its tooltip and a screen reader. */
label: string | null
} }
/** /**
* What the ride's pill says: the booking, then how long it takes when the timetable gives * The departure measured against the drive that gets there.
* both ends. A ride with no minutes is still the booking, and says so without a duration. *
* `ok` is the drive arriving by the check-in, `tight` after it but still before the ride
* leaves, `missed` after it has left. The ride keeps its day either way (`scheduleStopOf`
* pins it `dated`), so a drive a day late reads as missed rather than as tomorrow's ride.
*/ */
export function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string { export type RideState = 'ok' | 'tight' | 'missed'
const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '') export interface CheckInReading {
return duration ? `${carrier.title} · ${duration}` : carrier.title state: RideState
/** The badge's word: check-in, boarding for a train or a bus, or that the ride is missed. */
lead: string
/** Its figure: the check-in clock, how late the drive gets there, or when it does. */
value: string
/** The sentence behind the badge, with the timetable in it. */
hint: string
}
export interface RideReading {
title: string
duration: string | null
from: RideEnd
to: RideEnd
checkIn: CheckInReading | null
}
export interface TerminalReading {
end: RideEnd
/** A hire car's desk says which one it is, having no other end beside it to say it. */
desk: string | null
checkIn: CheckInReading | null
}
const DAY_MINUTES = 24 * 60
/** Trains and buses are boarded, the rest is checked in for (`CHECK_IN_MINUTES`). */
const BOARDED = new Set(['train', 'bus'])
const SAILS = new Set(['ferry', 'cruise'])
/** Which of the three timetable sentences a ride takes: they differ in the verb and the word for the check-in. */
function sentenceOf(type: string): 'Flight' | 'Ship' | 'Boarding' {
if (BOARDED.has(type)) return 'Boarding'
return SAILS.has(type) ? 'Ship' : 'Flight'
}
/** Minutes from the card's midnight as a clock, in the timetable's "+1" form past it. */
function clockOf(minutes: number, is12h: boolean): string {
const days = Math.floor(minutes / DAY_MINUTES)
const clock = formatClockTime(formatClock(minutes), is12h)
return days > 0 ? `${clock}+${days}` : clock
}
function rideEnd(stop: RoadtripStop, entry: ScheduleEntry | undefined, t: TranslationFn, is12h: boolean): RideEnd {
const carrier = stop.carrier!
const place = stripAirportCode(stop.name)
const clock = carrier.at ? formatClockTime(carrier.at, is12h) : null
// German, among others, has a word of its own for a plane leaving.
const key = carrier.role === 'departure' && carrier.type === 'flight' ? 'roadtrip.ride.departureFlight' : `roadtrip.ride.${carrier.role}`
return {
code: carrier.code,
place,
chip: carrier.code ?? place,
placeLine: carrier.code && carrier.code !== place ? place : null,
clock,
dayOffset: entry?.dayOffset ?? 0,
label: clock ? t(key, { time: clock }) : null,
}
}
/**
* The check-in against the drive, read off the departure's pin and the schedule's `late`
* finding at it: the pin is the timetable minus the check-in (`terminalStop`), and the
* finding is how far past the pin the chain gets there.
*/
function checkInOf(
departure: RoadtripStop,
warnings: readonly ScheduleWarning[],
t: TranslationFn,
is12h: boolean,
): CheckInReading | null {
const carrier = departure.carrier
if (carrier?.role !== 'departure') return null
const departs = parseClock(carrier.at)
const pinned = parseClock(departure.time)
if (departs === null || pinned === null) return null
const late = warnings.find(w => w.code === 'late')
const sentence = sentenceOf(carrier.type)
const word = t(sentence === 'Boarding' ? 'roadtrip.ride.boarding' : 'roadtrip.ride.checkIn')
const pin = clockOf(pinned, is12h)
const facts = { title: carrier.title, departs: clockOf(departs, is12h), pin }
if (!late) return { state: 'ok', lead: word, value: pin, hint: t(`roadtrip.ride.hint${sentence}`, facts) }
const reach = pinned + (late.minutes ?? 0)
const time = clockOf(reach, is12h)
const hint = t(`roadtrip.ride.lateHint${sentence}`, { ...facts, place: stripAirportCode(departure.name), time })
if (reach > departs) return { state: 'missed', lead: t('roadtrip.ride.missed'), value: t('roadtrip.ride.reachedAt', { time }), hint }
return { state: 'tight', lead: word, value: t('roadtrip.ride.lateBy', { time: formatDurationShort((reach - pinned) * 60) }), hint }
}
/**
* A ride that leaves and lands on the same day, as its block reads it: the booking, its
* minutes, both ends and the check-in with whatever the drive makes of it.
*/
export function rideReading(
ride: {
departure: RoadtripStop
arrival: RoadtripStop
entries: readonly [ScheduleEntry | undefined, ScheduleEntry | undefined]
/** The schedule's findings at the departure. */
warnings: readonly ScheduleWarning[]
seg: RouteSegment | undefined
},
t: TranslationFn,
is12h: boolean,
): RideReading {
return {
title: ride.departure.carrier!.title,
duration: rideDuration(ride.seg),
from: rideEnd(ride.departure, ride.entries[0], t, is12h),
to: rideEnd(ride.arrival, ride.entries[1], t, is12h),
checkIn: checkInOf(ride.departure, ride.warnings, t, is12h),
}
}
/** A terminal on a row of its own, its ride landing on another day, or a hire car's desk. */
export function terminalReading(
stop: RoadtripStop,
entry: ScheduleEntry | undefined,
warnings: readonly ScheduleWarning[],
t: TranslationFn,
is12h: boolean,
): TerminalReading {
const role = stop.carrier!.role
return {
end: rideEnd(stop, entry, t, is12h),
desk: role === 'pickup' || role === 'return' ? t(`reservations.span.${role}`) : null,
checkIn: checkInOf(stop, warnings, t, is12h),
}
}
/**
* The first ride of the day the drive reaches too late to catch, as the day's header names
* it, so a folded day still says so. Null while every ride is caught.
*/
export function missedRide(
day: Pick<RoadtripDay, 'stops' | 'schedule'>,
t: TranslationFn,
is12h: boolean,
): { label: string; hint: string } | null {
for (let i = 0; i < day.stops.length; i++) {
const stop = day.stops[i]!
const checkIn = checkInOf(stop, day.schedule.warnings.filter(w => w.index === i), t, is12h)
if (checkIn?.state !== 'missed') continue
const type = (CARRIER_TYPES as readonly string[]).includes(stop.carrier!.type) ? stop.carrier!.type : 'flight'
return { label: t(`roadtrip.day.rideMissed.${type}`), hint: checkIn.hint }
}
return null
}
/**
* Whether a booking chip opens for this reader. A transport has a detail view anybody
* may look at; a table or a ticket has only its editor, the split the planner page makes
* when it opens one. Without the right to edit bookings the chip stays a plain chip,
* the way the place inspector's booking strip stays inert (#2012).
*/
export function bookingOpens(booking: Pick<Reservation, 'type'>, canEditBookings: boolean): boolean {
return TRANSPORT_TYPES.has(booking.type) || canEditBookings
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip' export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround, rideGaps, riddenRanges, inRiddenRange, drivenPieces } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip' export type { LatLng, Bbox, CorridorHit, KmRange, RideGap } from '@trek/shared/roadtrip'
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { BedDouble } from 'lucide-react'
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking, leavesAfterCheckOut, staysAtTheirPlaces } from './nightBookend'
import type { BookendReading } from './roadtripRowModel'
import { serviceColor } from './roadtripModel'
// FE-BOOKEND-001 to FE-BOOKEND-010
const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
phase: 'morning',
variant: 'from',
name: 'Hotel Alpenblick',
until: null,
from: null,
reservationId: 41,
accommodationId: 5,
placeId: 900,
...over,
})
/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */
const t = (key: string, params?: Record<string, string | number | null>) =>
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
describe('a booked night as the rail and the phone say it', () => {
it('FE-BOOKEND-001: the badge names the edge of the stay the day stands at, the row names the hotel', () => {
expect(bookendBadge(reading({ variant: 'checkOut' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkOut')
expect(bookendBadge(reading({ variant: 'from' }), undefined, t, false).lead).toBe('roadtrip.bookend.from')
expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).lead).toBe('roadtrip.bookend.back')
expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkIn')
})
it('FE-BOOKEND-002: the check-out hour is the figure of the badge, in the clock of the reader', () => {
expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), undefined, t, false))
.toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: false, hint: 'roadtrip.stay.until(time=10:00)' })
expect(bookendBadge(reading({ variant: 'checkOut', until: '14:30' }), undefined, t, true).value).toBe('2:30 PM')
})
it('FE-BOOKEND-003: a morning that hands no room back, or an evening back at the stay, is the word alone', () => {
expect(bookendBadge(reading(), undefined, t, false)).toEqual({ lead: 'roadtrip.bookend.from', value: null, warning: false, hint: null })
expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).value).toBeNull()
})
it('FE-BOOKEND-010: a check-in evening carries the hour the room is ready, and leaving late turns the check-out badge', () => {
expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn', from: '15:00' }), undefined, t, false))
.toEqual({ lead: 'roadtrip.bookend.checkIn', value: '15:00', warning: false, hint: null })
expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), { arrival: '12:27', dayOffset: 0 }, t, false))
.toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: true, hint: 'roadtrip.bookend.afterCheckOut' })
})
it('FE-BOOKEND-007: a day that sets out after the room is handed back says so', () => {
const checkOut = reading({ variant: 'checkOut', until: '10:00' })
expect(leavesAfterCheckOut(checkOut, { arrival: '12:27', dayOffset: 0 })).toBe(true)
expect(leavesAfterCheckOut(checkOut, { arrival: '10:01', dayOffset: 0 })).toBe(true)
expect(leavesAfterCheckOut(checkOut, { arrival: '10:00', dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(checkOut, { arrival: '08:30', dayOffset: 0 })).toBe(false)
// Past midnight it is later than any hour of the morning before.
expect(leavesAfterCheckOut(checkOut, { arrival: '01:00', dayOffset: 1 })).toBe(true)
})
it('FE-BOOKEND-008: nothing to say without a check-out hour or a clock to hold it against', () => {
expect(leavesAfterCheckOut(reading(), { arrival: '12:27', dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(reading({ variant: 'checkOut', until: '10:00' }), { arrival: null, dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(reading({ variant: 'checkOut', until: '10:00' }), undefined)).toBe(false)
})
it('FE-BOOKEND-004: a tap opens the booking behind the night for somebody who may edit bookings', () => {
expect(bookendBooking(reading(), true)).toBe(41)
// The editor is the only view a hotel booking has, so anybody else gets the place.
expect(bookendBooking(reading(), false)).toBeNull()
// A stay entered without a booking has none to open.
expect(bookendBooking(reading({ reservationId: null }), true)).toBeNull()
})
it('FE-BOOKEND-005: the hotel wears the bed the stay chips wear', () => {
expect(BOOKEND_ICON).toBe(BedDouble)
})
it('FE-BOOKEND-009: on the disc the hotel stop wears, in its signage colour with the bed in white', () => {
expect(BOOKEND_DISC.background).toBe(serviceColor('hotel'))
expect(BOOKEND_DISC.color).toBe('#fff')
})
})
describe('the hotel a booked night stands for', () => {
it('FE-BOOKEND-006: is read off the trip place as it is now, and the stay row stands in only where there is none', () => {
const moved = { id: 1, place_id: 10, place_name: 'Old name', place_lat: 47.2, place_lng: 11.4 }
const unknown = { id: 2, place_id: 20, place_name: 'Elsewhere', place_lat: 48.1, place_lng: 11.6 }
const placeless = { id: 3, place_id: null, place_name: null, place_lat: null, place_lng: null }
const places = [
{ id: 10, name: 'Hotel Alpenblick', lat: 47.3, lng: 11.5 },
{ id: 30, name: 'Unrelated', lat: 1, lng: 2 },
]
const read = staysAtTheirPlaces([moved, unknown, placeless], places)
expect(read[0]).toEqual({ id: 1, place_id: 10, place_name: 'Hotel Alpenblick', place_lat: 47.3, place_lng: 11.5 })
expect(read[1]).toBe(unknown)
expect(read[2]).toBe(placeless)
// A place that lost its pin takes the bookend with it, as the server's join does.
expect(staysAtTheirPlaces([moved], [{ id: 10, name: 'Hotel Alpenblick', lat: null, lng: null }])[0])
.toMatchObject({ place_lat: null, place_lng: null })
// Nothing to change hands the list back as it came, so nothing downstream recomputes.
const current = [{ ...moved, place_name: 'Hotel Alpenblick', place_lat: 47.3, place_lng: 11.5 }]
expect(staysAtTheirPlaces(current, places)).toBe(current)
expect(staysAtTheirPlaces(current, [])).toBe(current)
})
})
@@ -0,0 +1,129 @@
import type { CSSProperties } from 'react'
import { BedDouble, type LucideIcon } from 'lucide-react'
import { formatClockTime } from '../../utils/formatters'
import { parseClock, serviceColor, type ScheduleEntry } from './roadtripModel'
import type { TranslationFn } from '../../types'
import type { BookendReading } from './roadtripRowModel'
/**
* How a booked night at the edge of a day reads on the rail and in the phone chain, and
* what a tap on it opens.
*
* One module for both shells, the way `carrierRide` is for a ride: the two would otherwise
* carry the same sentence and the same decision each, and the duplication budget does not
* stretch to that. What the row IS comes from the row model (`bookendReading`); this only
* says it, and reads the hotel it stands for off the trip's places (`staysAtTheirPlaces`).
*/
/** The bed the day plan's stay chips wear, so the hotel looks like itself here too. */
export const BOOKEND_ICON: LucideIcon = BedDouble
/**
* The disc the bed sits on: the hotel stop's own, in the road-signage colour its map pin
* wears. On a plain grey disc the night at the day's edge read as a terminal, and the same
* hotel wore two faces on one card, one where the day checks in and another where it
* comes back to it.
*/
export const BOOKEND_DISC: CSSProperties = { background: serviceColor('hotel'), color: '#fff' } // theme-lint-disable: road-signage palette
/**
* The badge under the hotel's name: which edge of the stay the day stands at, and the hour
* that edge names, the latest the room is handed back or the earliest it is ready. Both are
* labels, never the time the drive leaves or arrives at (#2357). A plan that sets out after
* the check-out turns the badge itself to a warning rather than adding a line to the row.
*/
export interface BookendBadge {
lead: string
value: string | null
warning: boolean
hint: string | null
}
export function bookendBadge(
reading: BookendReading,
entry: Pick<ScheduleEntry, 'arrival' | 'dayOffset'> | undefined,
t: TranslationFn,
is12h: boolean,
): BookendBadge {
const clock = reading.until ?? reading.from
const value = clock ? formatClockTime(clock, is12h) : null
const warning = leavesAfterCheckOut(reading, entry)
return {
lead: t(`roadtrip.bookend.${reading.variant}`),
value,
warning,
hint: warning
? t('roadtrip.bookend.afterCheckOut')
: reading.until && value ? t('roadtrip.stay.until', { time: value }) : null,
}
}
/**
* Whether the plan leaves the stay later than the room is handed back. The check-out hour
* never moves the start of the day, so a day that sets out at 12:30 from a room given up
* at 10:00 is a plan worth a second look, and the row says so instead of standing the two
* clocks side by side without a word.
*/
export function leavesAfterCheckOut(
reading: BookendReading,
entry: Pick<ScheduleEntry, 'arrival' | 'dayOffset'> | undefined,
): boolean {
const until = parseClock(reading.until)
const leaves = parseClock(entry?.arrival)
if (until === null || leaves === null) return false
return leaves + (entry?.dayOffset ?? 0) * 24 * 60 > until
}
/** The fields of a trip place the hotel of a stay is read from. */
export interface HotelPlace {
id: number
name: string
lat?: number | null
lng?: number | null
}
/** The fields of a stay row that name its hotel and where it stands. */
export interface StayAtPlace {
place_id?: number | null
place_name?: string | null
place_lat?: number | null
place_lng?: number | null
}
/**
* The stays with their hotel where the trip's places have it now.
*
* A stay row carries its place's name and position as they were when the stays were last
* fetched, and the planner fetches them again on a stay's own edit only. A pin moved on the
* hotel reaches its visits at once (`mergeAssignmentPlace`) and the stay row not at all, so a
* bookend seated from the row stood at the old spot beside its own stop at the new one: the
* day drove from the one to the other, and the server, which joins the place afresh, planned
* another drive. The place wins; the row stands in only for a place the list does not hold.
* Rows that need nothing come back as they were, and so does the list when none does.
*/
export function staysAtTheirPlaces<S extends StayAtPlace>(stays: readonly S[], places: readonly HotelPlace[]): readonly S[] {
if (!stays.length || !places.length) return stays
const byId = new Map(places.map(place => [place.id, place]))
let changed = false
const out = stays.map(stay => {
const place = stay.place_id == null ? undefined : byId.get(stay.place_id)
if (!place) return stay
const lat = place.lat ?? null
const lng = place.lng ?? null
if (stay.place_lat === lat && stay.place_lng === lng && stay.place_name === place.name) return stay
changed = true
return { ...stay, place_lat: lat, place_lng: lng, place_name: place.name }
})
return changed ? out : stays
}
/**
* The booking a tap on the row opens, or null when it opens the stay's place instead.
*
* The booking behind the night when there is one and this reader may open it: a hotel
* booking has only its editor, the split `bookingOpens` makes for a chip. Otherwise the
* place, which anybody may look at.
*/
export function bookendBooking(reading: BookendReading, canEditBookings: boolean): number | null {
return canEditBookings ? reading.reservationId : null
}
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from 'react'
/**
* The dropdown the corridor panel asks its question with: what to look for, and along which
* day. One shape for both, so the two sit side by side as one line of the same control.
*/
/** The closed control: one line, on the card's own ground, with the card's edge. */
export const DROPDOWN_TRIGGER =
'flex h-[32px] items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint'
/** The list it opens, under it and at least as wide. */
export const DROPDOWN_LIST =
'absolute top-full z-[var(--z-toast)] mt-1 w-max min-w-full overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur'
/** One option in that list. */
export const DROPDOWN_ROW = 'flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover'
/** Open or not, closed again by a press outside it or by Escape. */
export function useDropdown<T extends HTMLElement>() {
const [open, setOpen] = useState(false)
const ref = useRef<T>(null)
useEffect(() => {
if (!open) return
const onPointer = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open])
return { open, setOpen, ref }
}
@@ -175,3 +175,73 @@ describe('reanchorAfterReorder', () => {
expect(plan.vias).toEqual([]) expect(plan.vias).toEqual([])
}) })
}) })
/**
* A via behind a day's last stop shapes the drive into the next day, on a trip with
* connected days or a night drive: the map files a point dropped on that drive there, and
* a way chosen for it is written there. It is no leg of its own day, so the arithmetic
* above has to be told what it is (`carriedSeam`) or it takes it for one leading nowhere.
*/
describe('the drive into the next day (#2461)', () => {
it('FE-REANCHOR-023: a wholesale reorder that leaves the last stop last keeps the drive behind it', () => {
// Stops 10, 20, 30, 40; the via behind 40 is the join. Swapping the first two used to
// delete it, because a last stop has no leg.
const plan = reanchorByStopOrder([via(1, 0), via(9, 3)], [10, 20, 30, 40], [20, 10, 30, 40])
expect(anchors(plan.vias)).toEqual({ 1: 1 })
expect(plan.remove).toEqual([])
expect(isEmptyReanchoring(reanchorByStopOrder([via(9, 3)], [10, 20, 30, 40], [10, 20, 30, 40]))).toBe(true)
})
it('FE-REANCHOR-024: taking out the last stop takes the drive out of it along, instead of leaving it on no stop', () => {
const plan = reanchorAfterRemove([via(2, 2), via(9, 3)], 3, 4)
expect(plan.remove).toEqual([2, 9])
expect(plan.vias).toEqual([])
})
it('FE-REANCHOR-025: taking out any other stop leaves it behind the same last stop, one number down', () => {
expect(anchors(reanchorAfterRemove([via(9, 3)], 1, 4).vias)).toEqual({ 9: 2 })
expect(anchors(reanchorAfterRemove([via(9, 3)], 0, 4).vias)).toEqual({ 9: 2 })
// A day left with one stop has no leg, but still the drive out of it.
const single = reanchorAfterRemove([via(1, 0), via(9, 1)], 0, 2)
expect(anchors(single.vias)).toEqual({ 9: 0 })
expect(single.remove).toEqual([1])
})
it('FE-REANCHOR-026: a last stop dragged up the day takes its drive along, and so does a stop dragged behind it', () => {
// Left alone, the via sat at an index past the end of the day.
const up = reanchorAfterReorder([via(1, 0), via(9, 3)], 3, 1, 4)
expect(up.remove).toEqual([9])
expect(anchors(up.vias)).toEqual({})
// Behind the old last stop now comes the moved one. The point lay on the road into the
// next day, not on the way to the stop that took its place, so it goes as well.
const behind = reanchorAfterReorder([via(9, 3)], 1, 3, 4)
expect(behind.remove).toEqual([9])
expect(behind.vias).toEqual([])
// Anything moved in front of the last stop leaves it where it was.
expect(isEmptyReanchoring(reanchorAfterReorder([via(9, 3)], 0, 2, 4))).toBe(true)
})
it('FE-REANCHOR-027: a stop appended behind the last one keeps the point on the road to it', () => {
expect(isEmptyReanchoring(reanchorAfterInsert([via(9, 3)], 4, () => true))).toBe(true)
})
it('FE-REANCHOR-028: two stops swapped keep their leg, and the drive out of the one that is first now goes', () => {
for (const [from, to] of [[0, 1], [1, 0]] as const) {
const plan = reanchorAfterReorder([via(1, 0), via(9, 1)], from, to, 2)
expect(plan.remove, `${from}->${to}`).toEqual([9])
expect(plan.vias, `${from}->${to}`).toEqual([])
}
})
it('FE-REANCHOR-029: under Days and in the server’s sort, a last stop moved up the day takes its drive along too', () => {
// It followed its stop instead, and the drive from 40 to 20 inside the day went
// through a point on the road to tomorrow, for everybody on the trip.
const up = reanchorByStopOrder([via(1, 0), via(9, 3)], [10, 20, 30, 40], [10, 40, 20, 30])
expect(up.remove).toEqual([9])
expect(anchors(up.vias)).toEqual({})
const front = reanchorByStopOrder([via(9, 2)], [10, 20, 30], [30, 10, 20])
expect(front).toEqual({ vias: [], remove: [9] })
// Two stops swapped: the drive out of the one that is first now goes here as well.
expect(reanchorByStopOrder([via(9, 1)], [10, 20], [20, 10])).toEqual({ vias: [], remove: [9] })
})
})
@@ -1,9 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
arrivingReroutable,
bookendReading,
destinationCount, destinationCount,
firstStopOfPlace, firstStopOfPlace,
legReroutable, legReroutable,
movableWithin,
pickWarning, pickWarning,
resumeFoldsIntoBookend,
roadtripRows, roadtripRows,
stageClocks, stageClocks,
stageEnd, stageEnd,
@@ -13,9 +17,9 @@ import {
type StopRow, type StopRow,
} from './roadtripRowModel' } from './roadtripRowModel'
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel' import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' import type { BookendPhase, NightBookend, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-044 // FE-RTROW-001 to FE-RTROW-067
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop { function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return { return {
@@ -227,14 +231,16 @@ describe('roadtripRows stop detail', () => {
}) })
it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => { it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => {
// Where each finding really comes from: being late from the schedule, the drive's
// limits from `driveWarnings`.
const rows = stopRows( const rows = stopRows(
roadtripRows( roadtripRows(
day([stop('A'), stop('B')], { day([stop('A'), stop('B')], {
driveWarnings: [ driveWarnings: [
{ index: 0, code: 'overnight' }, { index: 0, code: 'overnight' },
{ index: 1, code: 'leg', overMinutes: 20 }, { index: 1, code: 'leg', overMinutes: 20 },
{ index: 1, code: 'late', minutes: 15 },
], ],
schedule: { entries: [entry(null), entry(null)], warnings: [{ index: 1, code: 'late', minutes: 15 }] },
}), }),
), ),
) )
@@ -242,6 +248,32 @@ describe('roadtripRows stop detail', () => {
expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 }) expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 })
}) })
it('FE-RTROW-067: being late, or leaving after the time set, reaches the row from the schedule, a terminal\'s included', () => {
const terminal = stop('HAM', {
carrier: { reservationId: 78, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' },
})
const rows = stopRows(
roadtripRows(
day([stop('A'), stop('B'), terminal], {
schedule: {
entries: [entry('09:00'), entry('10:00'), entry('14:15')],
warnings: [
{ index: 1, code: 'missedLeave', minutes: 20 },
{ index: 2, code: 'late', minutes: 579 },
// Only the lateness is read from the schedule, not the day it changes on.
{ index: 0, code: 'overnight' },
],
},
}),
),
)
expect(rows.map(r => r.warning)).toEqual([
null,
{ index: 1, code: 'missedLeave', minutes: 20 },
{ index: 2, code: 'late', minutes: 579 },
])
})
it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => { it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => {
const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')]))) const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')])))
expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240]) expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240])
@@ -604,4 +636,229 @@ describe('roadtripRows with a ride (#2428)', () => {
expect(firstStopOfPlace([flightDay()], -7)).toBeNull() expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen') expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
}) })
/** Bremen at 09:00, the airport at 12:20 for a 13:20 flight, landing at 14:30, nothing after. */
const endsOnRide = () =>
day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [seg(0), ride],
schedule: { entries: [entry('09:00'), entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
/** The flight first, the hotel after it. */
const opensOnRide = () =>
day([terminal('Munich Airport', 'departure', '06:00'), terminal('Hamburg Airport', 'arrival', '07:10'), stop('Hotel')], {
legs: [ride, seg(1)],
schedule: { entries: [entry('05:00', { departure: '06:00' }), entry('07:10'), entry('09:00')], warnings: [] },
})
it('FE-RTROW-052: a day that ends on a ride arrives at the arrival terminal, one that opens on a ride starts at the departure', () => {
// The head card printed the clock of the stop before the flight as where the day ends,
// while the ride block right below it printed the landing.
expect(stageClocks(roadtripRows(endsOnRide()))).toEqual({ start: '09:00', arrive: '14:30' })
expect(stageClocks(roadtripRows(opensOnRide()))).toEqual({ start: '05:00', arrive: '09:00' })
})
it('FE-RTROW-053: a day that is nothing but a ride still has both clocks', () => {
const rideOnly = day([terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30')], {
legs: [ride],
schedule: { entries: [entry('12:20', { departure: '13:20' }), entry('14:30')], warnings: [] },
})
expect(stageClocks(roadtripRows(rideOnly))).toEqual({ start: '12:20', arrive: '14:30' })
})
it('FE-RTROW-054: up next names the departure terminal before its check-in, and the arrival after take-off', () => {
// A lone terminal (a ride landing tomorrow) was already a candidate; the terminals
// inside a same-day ride were not, so the countdown skipped the airport.
expect(upNextStop(endsOnRide(), 10 * 60, true)?.row.stop.name).toBe('Munich Airport')
expect(upNextStop(endsOnRide(), 10 * 60, true)?.minutesUntil).toBe(140)
expect(upNextStop(endsOnRide(), 13 * 60 + 30, true)?.row.stop.name).toBe('Hamburg Airport')
expect(upNextStop(endsOnRide(), 15 * 60 + 1, true)?.minutesUntil).toBe(-31)
})
it('FE-RTROW-055: a stage that ends on a ride ends at its arrival terminal', () => {
const end = stageEnd(roadtripRows(endsOnRide()))
expect(end?.stop.name).toBe('Hamburg Airport')
expect(end?.time).toBe('14:30')
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
})
})
describe('the drive in from the day before (#2461)', () => {
/** Where yesterday ended: stored on day 6, its third stop, and drawn on no stop of this card. */
const yesterday = stop('Lüneburg', { ownerDayId: 6, ownerIndex: 2, legMode: null })
const drive: RouteSegment = { ...seg(4), distance: 120_000, duration: 5_400 }
/** A card joined to the one before it, with the drive and the line it is drawn on. */
const joined = (over: Partial<RoadtripDay> = {}) => day([stop('Berlin', { incomingLegMode: 'driving' }), stop('Potsdam')], {
arrivingLeg: drive,
arrivingFrom: yesterday,
arrivingLine: [[53.2, 10.4], [52.5, 13.4]],
...over,
})
it('FE-RTROW-056: heads the card with a row of its own that names where it leaves, and counts no stop for it', () => {
const rows = roadtripRows(joined())
expect(rows.map(r => r.kind)).toEqual(['arriving', 'stop', 'leg', 'stop'])
expect(rows[0]).toMatchObject({ kind: 'arriving', seg: drive, from: { name: 'Lüneburg' }, mode: 'driving' })
// Still two stops, numbered from one: the stop it leaves is not on this card.
expect(stopRows(rows).map(r => r.number)).toEqual([1, 2])
expect(destinationCount(joined())).toBe(2)
expect(stageClocks(rows)).toEqual(stageClocks(roadtripRows(joined({ arrivingLeg: undefined }))))
expect(stageEnd(rows)?.stop.name).toBe('Potsdam')
})
it('FE-RTROW-057: no row for a hop, a ride, or a drive whose start the card was not told', () => {
const hop: RouteSegment = { ...seg(0), distance: 80, duration: 60 }
const ride: RouteSegment = { ...seg(0), distance: 0, duration: 9 * 3600, mode: 'flight' }
for (const over of [{ arrivingLeg: hop }, { arrivingLeg: ride }, { arrivingFrom: undefined }, { arrivingLeg: undefined }]) {
expect(roadtripRows(joined(over)).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
}
})
it('FE-RTROW-058: other ways are offered on it exactly where a leg would be offered them', () => {
expect(arrivingReroutable(joined())).toBe(true)
// Nothing to offer against: no drive, no line, no stop it leaves or reaches.
expect(arrivingReroutable(joined({ arrivingLeg: undefined }))).toBe(false)
expect(arrivingReroutable(joined({ arrivingLine: undefined }))).toBe(false)
expect(arrivingReroutable(joined({ arrivingFrom: undefined }))).toBe(false)
expect(arrivingReroutable(joined({ stops: [] }))).toBe(false)
// A ride has no other way, and a hop is nothing to weigh.
expect(arrivingReroutable(joined({ arrivingLeg: { ...drive, mode: 'train' } }))).toBe(false)
expect(arrivingReroutable(joined({ arrivingLeg: { ...drive, distance: 80, duration: 60 } }))).toBe(false)
// A terminal leaves nothing a via can be filed behind.
const terminal = stop('Hamburg Airport', { carrier: { reservationId: 7, type: 'flight', role: 'arrival', title: 'LH 2020', code: null, at: '07:00' } })
expect(arrivingReroutable(joined({ arrivingFrom: terminal }))).toBe(false)
// An automatic night at either end is a marker on the road, not a stop anybody chose.
expect(arrivingReroutable(joined({ arrivingFrom: night() }))).toBe(false)
expect(arrivingReroutable(joined({ stops: [night('start'), stop('Potsdam')] }))).toBe(false)
})
})
describe('a booked night at the edge of the day', () => {
const reading = (phase: BookendPhase, over: Partial<NightBookend> = {}): NightBookend => ({
phase,
accommodationId: 5,
reservationId: 41,
checkingOut: false,
checkingIn: false,
checkOut: null,
...over,
})
const hotel = (phase: BookendPhase, over: Partial<NightBookend> = {}, at: Partial<RoadtripStop> = {}) =>
stop('Hotel Alpenblick', {
assignmentId: phase === 'morning' ? -6_000_000_014 : -6_000_000_015,
placeId: 900,
lat: 45,
lng: 7,
stopType: 'hotel',
dwellMinutes: 0,
bookend: reading(phase, over),
...at,
})
/** From the hotel, two places, back to the hotel, each with a clock. */
const loop = (over: Partial<RoadtripDay> = {}) => {
const stops = [
hotel('morning'),
stop('Lookout', { placeId: 1 }),
stop('Falls', { placeId: 2, ownerIndex: 1 }),
hotel('evening', {}, { ownerIndex: 2 }),
]
return day(stops, {
schedule: { entries: [entry('08:40'), entry('09:30'), entry('11:00'), entry('12:10')], warnings: [] },
...over,
})
}
it('FE-RTROW-059: reads as a check-out, a morning, an evening back or a check-in', () => {
const out = day([hotel('morning', { checkingOut: true, checkOut: '10:00' }), stop('Zoo')])
expect(bookendReading(out, 0)).toEqual({
phase: 'morning',
variant: 'checkOut',
name: 'Hotel Alpenblick',
until: '10:00',
from: null,
reservationId: 41,
accommodationId: 5,
placeId: 900,
})
expect(bookendReading(loop(), 0)).toMatchObject({ variant: 'from', until: null })
expect(bookendReading(loop(), 3)).toMatchObject({ variant: 'back', phase: 'evening' })
// The check-in day: back when the stay's own stop heads the card, a check-in without it.
const stayed = day([stop('Hotel Alpenblick', { lat: 45, lng: 7, night: true }), stop('Lookout'), hotel('evening', { checkingIn: true })])
expect(bookendReading(stayed, 2)?.variant).toBe('back')
const transfer = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true }, { name: 'Wallinga', lat: 46 })])
expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga', from: null })
// The hour the room is ready rides along on a check-in, as a label and nowhere else.
const ready = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true, checkIn: '15:00' }, { name: 'Wallinga', lat: 46 })])
expect(bookendReading(ready, 1)).toMatchObject({ variant: 'checkIn', from: '15:00' })
expect(bookendReading(ready, 0)?.from).toBeNull()
// Nothing to read on an ordinary stop, or past the end.
expect(bookendReading(loop(), 1)).toBeNull()
expect(bookendReading(loop(), 9)).toBeNull()
})
it('FE-RTROW-060: a row of its own, unnumbered, that counts as no destination', () => {
const rows = roadtripRows(loop())
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop', 'leg', 'stop'])
const stops = stopRows(rows)
expect(stops.map(r => r.number)).toEqual([null, 1, 2, null])
expect(stops.map(r => r.bookend?.variant ?? null)).toEqual(['from', null, null, 'back'])
expect(stops[0]!.service).toBe(true)
expect(destinationCount(loop())).toBe(2)
})
it('FE-RTROW-061: offers no other ways from or to the hotel, on a leg or on the drive in', () => {
expect([0, 1, 2].map(i => legReroutable(loop(), i))).toEqual([false, true, false])
const joined = (from: RoadtripStop, first: RoadtripStop) => day([first, stop('Falls')], {
arrivingLeg: { ...seg(3), distance: 120_000, duration: 5_400 },
arrivingFrom: from,
arrivingLine: [[45, 7], [48, 11]],
})
expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), stop('Lookout')))).toBe(true)
expect(arrivingReroutable(joined(hotel('evening'), stop('Lookout')))).toBe(false)
expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), hotel('morning')))).toBe(false)
})
it('FE-RTROW-062: the morning marker at the hotel the day sets out from is folded into its row', () => {
const resume = night('start')
const at = { ...resume, lat: 45, lng: 7 }
const folded = day([at, hotel('morning'), stop('Lookout')], {
schedule: { entries: [entry('08:00'), entry('08:00'), entry('09:00')], warnings: [] },
})
expect(resumeFoldsIntoBookend(folded, 0)).toBe(true)
expect(roadtripRows(folded).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
expect(stageClocks(roadtripRows(folded)).start).toBe('08:00')
// A marker somewhere else keeps its own row: the drive from there to the hotel is real.
const apart = day([resume, hotel('morning'), stop('Lookout')])
expect(resumeFoldsIntoBookend(apart, 0)).toBe(false)
expect(roadtripRows(apart)[0]).toMatchObject({ kind: 'auto', phase: 'resume' })
expect(resumeFoldsIntoBookend(folded, 1)).toBe(false)
})
it('FE-RTROW-063: the stage starts when the hotel is left and arrives when it is reached again', () => {
const rows = roadtripRows(loop())
expect(stageClocks(rows)).toEqual({ start: '08:40', arrive: '12:10' })
expect(stageEnd(rows)?.bookend?.variant).toBe('back')
})
it('FE-RTROW-064: a pin at the hotel opens the stay’s own stop, never a bookend', () => {
const own = stop('Hotel Alpenblick', { placeId: 900, assignmentId: 77, ownerDayId: 3 })
expect(firstStopOfPlace([loop(), day([own])], 900)).toBe(own)
expect(firstStopOfPlace([loop()], 900)).toBeNull()
})
it('FE-RTROW-065: a stop moves past anything but the hotel at the edge of its day', () => {
expect(movableWithin(loop(), 1)).toEqual({ up: false, down: true })
expect(movableWithin(loop(), 2)).toEqual({ up: true, down: false })
const plain = day([stop('A'), stop('B'), stop('C')])
expect([0, 1, 2].map(i => movableWithin(plain, i))).toEqual([
{ up: false, down: true },
{ up: true, down: true },
{ up: true, down: false },
])
})
it('FE-RTROW-066: up next is a place, not the hotel the day comes back to', () => {
// 11:30 is past both places and before the hotel: the last place is still next.
expect(upNextStop(loop(), 11 * 60 + 30, true)?.row.stop.name).toBe('Falls')
expect(upNextStop(loop(), 8 * 60 + 45, true)?.row.stop.name).toBe('Lookout')
})
}) })
@@ -1,5 +1,5 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel' import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import type { CarrierTerminal, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' import { isCarrierMode, isStoredStop, type BookendPhase, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
import { readStay } from './stayReading' import { readStay } from './stayReading'
/** /**
@@ -21,6 +21,12 @@ 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 }
/**
* The drive in from where the day before ended, above the card's first stop, on a trip
* with connected days. `from` is that last stop of the day before, which the card does
* not draw, so the row names it.
*/
| { kind: 'arriving'; seg: RouteSegment; from: RoadtripStop; mode: string | null }
/** /**
* A ride that leaves and lands on the day (#2428), as one row: the booking with both * 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 * its terminals inside it, in place of two stop rows and the leg between them. The
@@ -48,6 +54,76 @@ export interface StopRow {
/** How long the stop is stood at, which for one left at a set time is what that time leaves. */ /** How long the stop is stood at, which for one left at a set time is what that time leaves. */
dwellMinutes: number | null dwellMinutes: number | null
offRoadMeters: number | null offRoadMeters: number | null
/** What the row says about the booked night it stands for, when it is one; see `bookendReading`. */
bookend: BookendReading | null
}
/**
* A booked night at the edge of a day, as a row reads it (`seatNightBookends`).
*
* Four ways of saying it. The morning of the day the stay is handed back is a check-out,
* with the latest hour it may be; any other morning sets out from the stay. The evening
* of the day the stay begins is a check-in, unless the stay's own stop is already on the
* card before it, in which case the day comes back to it like any other evening.
*/
export interface BookendReading {
phase: BookendPhase
variant: 'checkOut' | 'from' | 'back' | 'checkIn'
name: string
/** The latest the room is handed back, on the check-out morning. A label, never a departure. */
until: string | null
/** From when the room is ready, on a check-in evening. A label, never an arrival. */
from: string | null
/** The booking a tap opens, or null for a stay entered without one. */
reservationId: number | null
accommodationId: number
placeId: number
}
export function bookendReading(day: Pick<RoadtripDay, 'stops'>, index: number): BookendReading | null {
const stop = day.stops[index]
const bookend = stop?.bookend
if (!stop || !bookend) return null
const morning = bookend.phase === 'morning'
const stayStopBefore = day.stops
.slice(0, index)
.some(s => isStoredStop(s) && s.lat === stop.lat && s.lng === stop.lng)
const evening = bookend.checkingIn && !stayStopBefore ? 'checkIn' : 'back'
return {
phase: bookend.phase,
variant: morning ? (bookend.checkingOut ? 'checkOut' : 'from') : evening,
name: stop.name,
until: morning && bookend.checkingOut ? bookend.checkOut : null,
from: evening === 'checkIn' ? (bookend.checkIn ?? null) : null,
reservationId: bookend.reservationId,
accommodationId: bookend.accommodationId,
placeId: stop.placeId,
}
}
/**
* Whether the automatic morning marker at `index` is the same point as the hotel the day
* sets out from right after it. The hotel's row carries that morning, clock and all, and a
* "continue journey" line above it would say the same thing twice.
*/
export function resumeFoldsIntoBookend(day: Pick<RoadtripDay, 'stops'>, index: number): boolean {
const stop = day.stops[index]
const next = day.stops[index + 1]
return stop?.automaticNight?.phase === 'start'
&& next?.bookend?.phase === 'morning'
&& stop.lat === next.lat
&& stop.lng === next.lng
}
/**
* Where the stop at `index` may be moved by one: not past a booked night at the day's
* edge, which is no stored stop and holds its place. Past anything else, as before.
*/
export function movableWithin(day: Pick<RoadtripDay, 'stops'>, index: number): { up: boolean; down: boolean } {
return {
up: index > 0 && !day.stops[index - 1]?.bookend,
down: index < day.stops.length - 1 && !day.stops[index + 1]?.bookend,
}
} }
/** /**
@@ -80,17 +156,18 @@ export function stageOf(days: readonly RoadtripDay[], dayId: number | null): Roa
return days.find(d => d.dayId === dayId) ?? null return days.find(d => d.dayId === dayId) ?? null
} }
function stopRow( function stopRow(day: RoadtripDay, index: number, number: number | null): StopRow {
stop: RoadtripStop,
index: number,
number: number | null,
schedule: RoadtripDay['schedule'],
driveWarnings: readonly ScheduleWarning[],
): StopRow {
// Only ever called for a real stop: an automatic night is the shell's own marker // Only ever called for a real stop: an automatic night is the shell's own marker
// for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead. // for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead.
const entry = schedule.entries[index] const stop = day.stops[index]
const mine = driveWarnings.filter(w => w.index === index) const entry = day.schedule.entries[index]
// Being late comes from the schedule and the drive's own findings from the limits: two
// lists, and a row reading only the second never showed a stop reached late, or a ride
// the drive gets to after it has left.
const mine = [
...day.driveWarnings,
...day.schedule.warnings.filter(w => w.code === 'late' || w.code === 'missedLeave'),
].filter(w => w.index === index)
return { return {
kind: 'stop', kind: 'stop',
stop, stop,
@@ -103,6 +180,7 @@ function stopRow(
warning: pickWarning(mine), warning: pickWarning(mine),
dwellMinutes: readStay(stop, entry).minutes, dwellMinutes: readStay(stop, entry).minutes,
offRoadMeters: stop.offRoadMeters ?? null, offRoadMeters: stop.offRoadMeters ?? null,
bookend: bookendReading(day, index),
} }
} }
@@ -156,6 +234,20 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
const rows: RoadtripRow[] = [] const rows: RoadtripRow[] = []
const spills = day.spills ?? [] const spills = day.spills ?? []
// The drive in from yesterday, where the desktop rail draws its band: above the first
// stop, because that is where it happens. A hop is left out like any other, and so is
// a ride: the terminal row it lands on already carries the booking, and a pill would
// read "No route" under a flight that has no road.
const arriving = day.arrivingLeg
if (arriving && day.arrivingFrom && !isHop(arriving) && !isCarrierMode(arriving.mode)) {
rows.push({
kind: 'arriving',
seg: arriving,
from: day.arrivingFrom,
mode: day.stops[0]?.incomingLegMode ?? day.arrivingFrom.legMode ?? null,
})
}
let number = 0 let number = 0
day.stops.forEach((stop, i) => { day.stops.forEach((stop, i) => {
const spill = spills.find(s => s.at === i) const spill = spills.find(s => s.at === i)
@@ -176,6 +268,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return
const automatic = !!stop.automaticNight const automatic = !!stop.automaticNight
// The morning marker at the hotel the day sets out from is that hotel's row.
if (resumeFoldsIntoBookend(day, i)) return
if (automatic) { if (automatic) {
rows.push({ rows.push({
kind: 'auto', kind: 'auto',
@@ -188,8 +282,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
index: i, index: i,
carrier: stop.carrier, carrier: stop.carrier,
seg: day.legs[i], seg: day.legs[i],
departure: stopRow(stop, i, null, day.schedule, day.driveWarnings), departure: stopRow(day, i, null),
arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings), arrival: stopRow(day, i + 1, null),
}) })
// The road out of the arrival belongs to this row too; the pass below only // The road out of the arrival belongs to this row too; the pass below only
// looks at the index it is on. // looks at the index it is on.
@@ -200,7 +294,7 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
// place the trip is for: it carries no number, the way a service stop carries none. // place the trip is for: it carries no number, the way a service stop carries none.
const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
if (!unnumbered) number += 1 if (!unnumbered) number += 1
rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings)) rows.push(stopRow(day, i, unnumbered ? null : number))
} }
pushLeg(i) pushLeg(i)
@@ -248,6 +342,36 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
// index of the stop after it. The road INTO a departure terminal leaves a stored // 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. // stop and can be offered other ways like any other.
&& !day.stops[index].carrier && !day.stops[index].carrier
// The drive from the hotel a day sets out from, or to the one it ends at, takes no
// via: the morning's has no index to file one at, and the evening's index is the
// road into tomorrow's, which a choice here would bend instead.
&& !day.stops[index].bookend
&& !day.stops[index + 1].bookend
}
/**
* Whether the drive arriving at the head of a connected card can be offered other ways.
*
* The same rule as `legReroutable`, read for the one drive a card holds without a stop of
* its own at its start: it leaves the last stop of the day before (`arrivingFrom`), and a
* choice is filed behind that stop, on that day, where the map already files a point
* dropped on this drive. It needs its road and the line it is drawn on, since that is
* what the picker offers as the current way. A ride has no other way, a hop is nothing to
* weigh, a terminal leaves nothing a via can be filed behind, and an automatic night at
* either end is a marker on the road rather than a stop anybody chose.
*/
export function arrivingReroutable(day: RoadtripDay): boolean {
const seg = day.arrivingLeg
const from = day.arrivingFrom
const to = day.stops[0]
return !!seg && !!from && !!to && !!day.arrivingLine
&& !isCarrierMode(seg.mode)
&& !isHop(seg)
&& !from.carrier
&& !from.automaticNight
&& !to.automaticNight
&& !from.bookend
&& !to.bookend
} }
/** Stops that carry a number, for a count that agrees with the numbering above. */ /** Stops that carry a number, for a count that agrees with the numbering above. */
@@ -255,6 +379,15 @@ export function destinationCount(day: RoadtripDay): number {
return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
} }
/**
* The rows with each ride opened up into its two terminals, in place. A ride row holds
* them instead of two stop rows of their own, so a reader walking the rows for stops
* has to look inside it, or a day that ends on a flight ends at the stop before it.
*/
function unfoldRides(rows: readonly RoadtripRow[]): RoadtripRow[] {
return rows.flatMap(row => (row.kind === 'ride' ? [row.departure, row.arrival] : [row]))
}
/** /**
* The two clocks a stage is headed with: when it starts, and when you reach its last place. * The two clocks a stage is headed with: when it starts, and when you reach its last place.
* *
@@ -284,7 +417,7 @@ export function destinationCount(day: RoadtripDay): number {
export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } { export function stageClocks(rows: readonly RoadtripRow[]): { start: string | null; arrive: string | null } {
let start: string | null = null let start: string | null = null
let arrive: string | null = null let arrive: string | null = null
for (const row of rows) { for (const row of unfoldRides(rows)) {
if (row.kind === 'stop' && row.time) { if (row.kind === 'stop' && row.time) {
start ??= row.time start ??= row.time
arrive = row.time arrive = row.time
@@ -296,7 +429,8 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
} }
/** /**
* The stop a stage ends on: the last one the chain draws, a service stop included. * The stop a stage ends on: the last one the chain draws, a service stop or the arrival
* terminal of a ride included.
* *
* The map half names it in its bar and opens it on a tap, so the name, the clock beside it * The map half names it in its bar and opens it on a tap, so the name, the clock beside it
* and the sheet the tap brings up all come off this one row and cannot name three different * and the sheet the tap brings up all come off this one row and cannot name three different
@@ -308,8 +442,9 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
* There is nothing for a tap to open then, and the bar says so by not being a button. * There is nothing for a tap to open then, and the bar says so by not being a button.
*/ */
export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null { export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
for (let i = rows.length - 1; i >= 0; i--) { const unfolded = unfoldRides(rows)
const row = rows[i] for (let i = unfolded.length - 1; i >= 0; i--) {
const row = unfolded[i]
if (row.kind === 'stop') return row if (row.kind === 'stop') return row
} }
return null return null
@@ -326,12 +461,13 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
* traveller reading the drive from its start reaches first. * traveller reading the drive from its start reaches first.
* *
* An automatic night can sit on a place's position, but nobody chose to stop there, so it * An automatic night can sit on a place's position, but nobody chose to stop there, so it
* never answers. Null when none of the days stops at the place, which is a pin the road * never answers. Neither does a booked night at a day's edge: it is the stay's place, and
* no stop of the day, so the inspector answers for the hotel instead. Null when none of the days stops at the place, which is a pin the road
* trip cannot explain and the caller hands to the plan's own place inspector instead. * trip cannot explain and the caller hands to the plan's own place inspector instead.
*/ */
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null { 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.carrier && s.placeId === placeId) const stop = day.stops.find(s => isStoredStop(s) && s.placeId === placeId)
if (stop) return stop if (stop) return stop
} }
return null return null
@@ -355,7 +491,7 @@ export function upNextStop(
isToday: boolean, isToday: boolean,
): { row: StopRow; minutesUntil: number } | null { ): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return null if (!day || !isToday) return null
const timed = roadtripRows(day) const timed = unfoldRides(roadtripRows(day))
.filter((r): r is StopRow => r.kind === 'stop' && !r.service) .filter((r): r is StopRow => r.kind === 'stop' && !r.service)
.map(row => ({ row, at: clockMinutes(row.time) })) .map(row => ({ row, at: clockMinutes(row.time) }))
.filter((x): x is { row: StopRow; at: number } => x.at != null) .filter((x): x is { row: StopRow; at: number } => x.at != null)

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