Compare commits

...

141 Commits

Author SHA1 Message Date
Konstantinos Thermos 48cd46f818 feat(pdf): put the trip's route map in the export 2026-09-15 15:58:36 +03:00
Maurice 2efa843306 fix(test): the MCP endpoint cases find their own row again
FE-COMP-INTEGRATIONS-004, -015 and -016 took the first <code> and the first
Copy button on the tab and expected the MCP endpoint. Since the API keys card
shows the /api/v1 address with a copy button of its own (#2371), and it sits
above the MCP section, those queries landed on the API card: the URL read
/api/v1 and the click copied it. The cases now go through the endpoint's own
label.
2026-09-14 23:23:22 +02:00
Maurice 53eeba3c1b Merge pull request #2372 from liketrek/fix/dawarich-tester-feedback
fix(dawarich): three things a tester on dev1 ran into
2026-09-14 23:09:33 +02:00
Maurice c1d55c463c fix(dawarich): three things a tester on dev1 ran into
The Atlas country check timed out. It asked Dawarich for a year of visited
cities in one request, and Dawarich computes that over every point in the
window with nothing cached, so a dense recorder never got under the 15 s the
server allows. The year is now asked for a month at a time, one request after
another, and folded back together. The windows sit a second apart because
Dawarich reads the bounds as inclusive.

The recorded route only appeared after a reload. Every Dawarich call from the
browser carried the shared 8 s timeout, so the browser gave up while the server
carried on and cached the answer for the next page load. Calls that make the
server talk to Dawarich now wait two minutes, the Atlas four. On the GL
renderers the draw also gave up whenever isStyleLoaded() was false, which it is
while any tile is still loading, and only retried on style.load. It now waits
for idle. The hazard overlay had the same guard and gets the same wait. The
pill shows a ring while the route loads, so a slow first fetch no longer looks
like "done, nothing there".

A collapsed day now takes its recorded route off the map, following the same
folds the planner already applies to places, the road trip sidebar's included.
2026-09-14 23:00:57 +02:00
Maurice 60cbb41d5f fix(roadtrip): put a corridor hit on the drive from the day before ahead of the first stop
The corridor searches the whole line a card draws, and a card that opens
with a drive from the day before (a night carried over, or the joined road
on connected days) draws that drive ahead of its first stop. The insert
index still came from insertIndexForAlong, which never answers 0, so a
charger found on that stretch was added after the first stop and the day
drove there, turned back to the charger and came the same way again.

insertIndexFor now answers 0 for a hit before the first stop when the card
arrives from an earlier stop, and dropping a hit on the map asks it instead
of computing its own index, so a click and a drop cannot disagree.
2026-09-14 22:43:46 +02:00
Maurice 2ffe4d6054 fix(roadtrip): file a via on the drive between connected days after the stop it leaves
With connected days the drive from one day's last stop to the next day's
first is drawn at the head of the arriving card, in the colour of the day
before. anchorFor only knew that stretch as a night spill, so a click on it
anchored to the arriving card's first stop: the via was stored on the leg
after that stop, and the next day drove out to the point, turned and came
back, while the stretch that was clicked kept its shape.

assembleRoadtrip now names the stop that joined drive leaves from, and
anchorFor treats it like the stop a night drive left from. Placing and
dragging both resolve to the previous day's last stop, which is the anchor
the seam request already reads its vias from.
2026-09-14 22:36:29 +02:00
Maurice 1b49995c15 Merge pull request #2371 from liketrek/fix/api-keys-settings-polish
fix(settings): bring the API keys section and its dialog in line with the rest of TREK
2026-09-14 22:05:57 +02:00
Maurice 25282ea1c5 fix(settings): bring the API keys section and its dialog in line with the rest of TREK
The section stacked a lonely button, a centred line and a small print hint
on top of each other, and the create dialog used bare browser checkboxes
that ignore the accent. The dialog now runs on the shared Modal with
selectable scope tiles, keys render as cards with their scopes as chips,
deletion goes through ConfirmDialog, and the endpoint sits in its own row
with a copy button.

Pressing Enter in the name field with every scope switched off used to
create a key: the empty list was dropped from the request and the server
stored it as a key that reads everything. Creation is now refused until at
least one scope is picked.

A failed list load no longer reads as "no keys yet", the create button
stops at the server's limit of ten keys, a refused clipboard says so, and
SQLite timestamps are parsed so Safari does not show Invalid Date.
2026-09-14 21:59:27 +02:00
Maurice da679e6266 feat(roadtrip): add a stop by hand in the form the rest of TREK adds places in
The dialog built for this last round was its own thing and showed it: the
suggestion list was barely legible, clipped at the bottom, and never more
than two rows tall. It also lacked most of what the ordinary add-place form
has had for years.

So it is gone, and "Add manually" opens that form instead, in a service-stop
mode. The category control becomes the kind of stop and the time spent
there, the costs section is dropped, and an "Add between" row says where on
the drive it goes. Everything else comes along rather than being rebuilt:
the typed-ahead search with its source marks, the coordinate fields for a
charger no index knows, address, website, phone, notes, times, files and the
duplicate check. With the mode absent the form is the same component it was,
which its own suites still pin.

Where the stop lands is worked out when it is saved rather than when the
form opens, because the coordinate only exists once a place has been chosen.
One function answers both what the select shows and where the stop goes, so
the two cannot disagree, and the write goes through the same assignment path
as every other insert, via re-anchoring included: a stop pushed into a day
shifts every via anchored at or after it, and without re-anchoring first the
drawn road runs forward, doubles back and runs out again.

The select recommends rather than merely offering. Every leg of every routed
day is measured against the place separately and they are listed nearest
first, each with its own distance, so the preselected one visibly is the
closest and overruling it is an informed choice. The word they all shared
moved into the caption.

A stop with no position at all is not filed on a drive by guesswork. It
keeps its kind and goes to the trip's places, and the form says so and says
how to change it.
2026-09-14 16:22:18 +02:00
Maurice 68ce177901 fix(test): CHARGING-013 failed on a date, not on a count
The case asserts no availability counter is shown for an ambiguous station, and
looked for one with an unanchored /\d+\/\d+/. The attribution line beside it ends
in the source's updatedAt run through toLocaleString(), and tests/setup.ts only
forces en-US on toLocaleDateString, so that timestamp reads "14.9.2026" on a
German machine and "9/14/2026" on the CI runner. The regex matched the second
one. Green where it was written, red on Linux, and red for a reason that has
nothing to do with charging.

Anchored at the start instead. Both places that print a count begin with it, so
the case still says what it meant to say.
2026-09-14 14:26:15 +02:00
Maurice 3841412b22 fix(roadtrip): the two search actions stop cutting their labels in half
"Add manually" did not fit beside "Search" and rendered as "Add manu…",
which is neither the label nor a shape anybody recognises.

The button face now carries one short word, with the full sentence kept as
its accessible name and its tooltip, so nothing is lost to a reader or a
pointer. Every locale got its own short form rather than a clipped version
of the long one: Selbst, A mano, Ručně, Elle, 手動.

Below two hundred pixels the row drops both words instead of shortening
them, and the two actions are their icons. The width is the row's own,
measured, not the window's: this column is resizable, so it can be narrow on
a wide screen and a media query would answer the wrong question.

The search button also stops restating the progress. Its label used to grow
into "Searching 3 of 12" while a run was on, which is the longest string in
the panel and the very sentence the progress row directly underneath already
prints next to the share as a figure. It keeps its word, disabled, with the
spinner in front of it.
2026-09-14 13:50:44 +02:00
Maurice 049fb76965 Merge pull request #2366 from liketrek/fix/roadtrip-feedback
feat(roadtrip): the testers' round, and a defect it uncovered
2026-09-14 13:33:36 +02:00
Maurice 54b94cf9e3 docs(wiki): the manual stop, clearing a search, and charging before the add
Three panel changes and one new MCP tool that the pages did not describe
yet. Also records the two rules the code enforces and a reader would
otherwise have to discover: the charging panel is fetched once per opened
dialog rather than per row of a result list, and the lookup tool carries no
power rating or socket count because those are OpenStreetMap tag data that
travels with the corridor result instead.

The wiki syncs on a push to main, so this rides along until the next
release rather than appearing now.
2026-09-14 13:22:26 +02:00
Maurice b058b63f6e fix(roadtrip): the manual-stop dialog looped on a hint rebuilt per render
Three things the first CI run found, all of them in the new tests rather
than in what they cover.

The dialog's search effect depended on the location hint object. Its own
hook memoises, so nothing looped in the app, but the effect sets state on a
query too short to ask about, and a caller whose hook hands back a fresh
literal per render turns that pair into a render loop that never yields. The
suite mocked it exactly that way, and the worker hung before a single test
reported, which is what took the client coverage job past half an hour. The
hint is three numbers, so the effect now turns on those and reads the value
out of a ref when the request goes out. The mock hands back one object, the
way the real hook does.

The suite also installed fake timers in a beforeEach, which deadlocks
against React's scheduler and Testing Library's own waiting. Every wait is
a waitFor now, and the two cases that have to prove something did not
happen sit out a real pause. The debounce boundary is no longer asserted
against a wall clock, because a busy runner stretches any such window.

The charging fixtures are pinned to a fixed instant and the service reads
the real clock against a twenty-minute freshness window, so every answer
through it was stale by construction and available came back null. Date.now
is pinned for those tests, never the timers, since the requests carry an
AbortSignal.timeout that has to keep running.

And the unidentified-station test counted its two "no reliable data" lines
before the answer landed: the price line already says it while the request
is out, so the count settled on one and passed for the wrong reason.
2026-09-14 13:20:21 +02:00
Maurice f24827e7ed Merge pull request #2365 from liketrek/test/dawarich-coverage
test(dawarich): the integration that shipped without a suite
2026-09-14 13:02:35 +02:00
Maurice 00d00be1c0 feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked
for it while still deciding. The availability and the published tariffs were
reachable only for a saved place: the route takes a places id, and a hit
found along the route has no row yet.

ChargingService.load already needed nothing but a coordinate and a name, so
it now has a public method that says so, behind a trip-scoped route with the
same guard stack, a Zod contract in shared, and the parallel MCP tool in this
same change. Same service, same caches, so a station looked at in the dialog
and then added keys the entry already fetched. Trip-scoped even though the
trip is not part of the question: without :tripId the trip access guard goes
too, and what is left is an open proxy in front of somebody else's public API.

It renders in the add dialog, and only while the selected kind is charging,
so a fuel or food stop costs nothing and one opened dialog is one lookup.
Deliberately not per result row: the corridor search publishes box by box,
every mounted panel owns a refresh timer, a cache miss can cost seven
upstream requests, and a station in a dense charging park is the case the
upstream matcher refuses as ambiguous.

Power and socket counts are not in this answer and were not asked of it.
They are OSM tag data and already ride along on the corridor row for free.
2026-09-14 12:28:47 +02:00
Maurice accdd5905e feat(roadtrip): add a stop the search never found, and clear the ones it did
Four things testers asked for in the "Along the route" panel.

Add manually. The corridor search reads OpenStreetMap, and a good share of
the chargers standing at a motorway junction are not in it, Tesla
Superchargers among them. The way round it was to leave road trip mode, add
the place under Days, drag it onto the right day, come back and mark it a
charging stop. The Search button now shares its row with a second one that
asks the two questions those four steps answered: which place, and where on
the drive. The place comes from a debounced typeahead over the place search;
the position from anchorFor, the same projection a via is placed with, and
it is offered as a dropdown of every leg of every routed day so it can be
overridden. Unlike a via, a place far from the drawn line is not refused,
only flagged: that place is the reason this dialog exists. From there it
hands over to the stop popup a corridor hit already goes through, so the
kind and the dwell are decided once for both paths and the write stays
saveStopDraft.

Clear results. A search that turned up the wrong thing left its hits in the
list and its pins on the map, and the only way out was switching to the day
plan and back. The search hook has exposed a clear() since it was written
and nothing ever called it. A control in the results header does now, and
takes the four narrowing states with it, because they were set for a
question that no longer has an answer.

Charging as the default for an electric car. "Looking for" opened on Fuel
whatever the vehicle. It opens on Charging when the trip's vehicle is
electric, and is unchanged for combustion, for an empty value and for a
vehicle nobody set, so nothing moves for anybody who never opened the
dialog. Seeded once, never over a category the traveller picked or a search
in flight.

Clicking a result moves the map to it. The row had no click handler at all;
only the trailing Add button did anything. The badge and the text are one
activatable target with that button as its sibling, which keeps the li a
list item and avoids a button inside a button, and it goes through the same
focusRoadtripPoint the rail uses, which closes the refuel and alternatives
panels first because those outrank the camera.
2026-09-14 12:28:30 +02:00
Maurice f4bbb0fe5d fix(roadtrip): the alternatives list compared two routers' drive times
The ways of driving one leg come from OSRM, except the toll-free or
motorway-free offer beside them: the shipped OSRM hosts are built without
excludable road classes and answer exclude= with HTTP 400, so that one is
asked of the avoidance router instead.

Their figures were then read against each other. The quickest was elected
across both, which is what decides the road drawn as the one being driven,
and every other road was labelled with how far behind that election it sat.
The two engines agree on the road to a tenth of a percent and not at all on
how fast it may be driven: over twelve European legs the per-leg time ran
from 14.7 % under OSRM's to 13.2 % over, with the sign following the region.
On a Spanish motorway leg that gap alone is enough to crown a toll-free
detour and call the motorway somebody is on the slow way round.

An offer now carries the engine that timed it. It takes no part in electing
the quickest, no difference is printed across the two, and the bar says where
the figure came from. Each route still reports its own time; only the
subtraction is refused. An instance running its own OSRM through the MLD
pipeline answers exclude itself, so there both figures are one engine's and
the comparison works exactly as before.
2026-09-14 12:28:12 +02:00
Maurice a6e8c66609 test(dawarich): the integration that shipped without a suite
Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on
SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are
Dawarich's own. 946 cases across 30 files take the surface to 100% in shared,
99.7 in server and 99.6 in client, and the gate to roughly 85%.

What stays uncovered is thirteen type-narrowing guards and defensive fallbacks
the only caller cannot reach, each argued in the file beside it rather than
forced with a cast. No source, config or coverage threshold is touched.
2026-09-14 10:39:49 +02:00
Maurice b2384a7523 test(places): the delete doubles answer in the shape the service now uses
remove and removeMany report what the nights they cancelled took down with
them, so the stubs in the controller and plugin-RPC suites report it too. Plus
a case for the announcement itself, which had none: place:deleted says nothing
about a partner booking or an expense linked by reservation_id.
2026-09-13 23:49:01 +02:00
Maurice 8cddd445ba test(roadtrip): the assemble spec holds up under shared's stricter typecheck
noUncheckedIndexedAccess makes every index access optional, DriveLimits wants
its two minute ceilings named even when they are null, and the distance unit is
metric/imperial rather than a unit label.
2026-09-13 23:49:01 +02:00
Maurice 39d832c772 test(journey): the entry picker takes clips, so the test may not insist on stills
FE-PAGE-JOURNEYDETAIL-105 still looked for accept="image/*" on the editor's
file input after it learned to take video (#2341), which is the one red test on
dev. The camera input beside it stays image-only and the test says why.
2026-09-13 23:49:01 +02:00
Maurice f4380727c7 fix(roadtrip): the range warning and the per-leg figures in the server plan
deriveDriveWarnings takes a two-slot "does this stop refuel" pair, slot 0 for
the stop a leg leaves and slot 1 for the stop it reaches, and the assembler
hard-coded slot 1 to false. The suppression the model was written and tested
for could therefore never fire through it: a day that reaches a charger 700 km
in reported running dry at the charger, and a run of warnings that should
collapse to one stayed at full length. Pinned by a spec of its own now, since
the wrapper had none.

The server's plan built each leg by spreading the whole run's segment and then
overriding only its metres and seconds, so every leg of a multi-stop run
carried the run's distance text, its duration text and its polyline midpoint:
leg one of a 480 km day reported 100 km with "480 km" printed beside it. The
four texts and the midpoint are per leg now, in the reader's own unit rather
than the router's hard-coded kilometres, which also ends a response that mixed
miles in its day labels with kilometres in its legs.
2026-09-13 23:49:01 +02:00
Maurice 61880ce7d6 fix(roadtrip): the vias, the alternatives, the pacing and the missing warning
Choosing another road on a leg that already carried a via appended the new
point behind the old one, so the day drove south to the first and north to the
second: a zigzag matching neither the line drawn under the button nor the
distance printed on it. The alternatives are computed for the two bare
endpoints, so the write has to replace the leg, which is what the direct branch
beside it already did.

A day's vias were only loaded while road-trip mode was being looked at, and the
three handlers that re-anchor them after a stop is added, removed or reordered
are reachable from the place inspector in both modes. With the switch off they
computed their correction from an empty list and wrote nothing, so a deleted
stop left the detour on a leg nobody chose, for the traveller and for every
collaborator. They are loaded whenever the addon is on now.

Seam routing paced itself by sleeping after each request, and storing a result
shortened the key its own effect depends on: React tore the effect down
mid-sleep, the cleanup aborted it, and the next request went out immediately.
Thirteen seams left at round-trip speed, the shared routing hosts refused the
tail, and a refused seam changes no state so nothing ever retried it. The wait
now happens before the request, against a clock that outlives the effect.

The "avoidance not honoured" badge could never appear on the rail: the field
behind it was published every round and never written. Only the second engine
reports what it achieved against what was asked, and the rail plans in the
browser, so it never saw the server's own copy — a motorway crossing read as a
motorway-free drive.

Two smaller ones. `trip.toast.loadError` has been called since the first commit
and exists in no locale, so both call sites printed the key itself; it is
written now in all 23. And the phone shell honoured the road-trip session flag
while having no rail, no drive lines and no switch to leave it, so narrowing a
desktop window took the trip overview away and listed every booked night twice.
2026-09-13 23:49:00 +02:00
Maurice c8cb88133f fix(maps): the projection, the labels and two unbounded reads
A map keeps the datum it was built with, and Leaflet cannot change one
afterwards. The basemap could still turn into Amap's after the fact: a first
settings request that fails leaves the store on defaults for the session and is
healed later, and the raster layer then drew GCJ-02 tiles under a map still
projecting WGS-84, putting every marker, route and cluster a few hundred metres
off the street beneath it until the planner was left and reopened. The datum is
part of the element's identity now, so a flip rebuilds the map. Only the datum:
keying on the whole tile URL would throw the view away on every template edit.

Index POIs were labelled by an exact lookup of a term the index matches as a
substring, so any leaf that is not literally a term missed and took the first
category of the request instead. In a corridor search that is whichever pill
was tapped first, and the panel groups and colours on that field: a trattoria
came back as a petrol station, or a petrol station as tonight's hotel. The
label now comes from the term that actually matched, longest first.

Two outbound reads were missing what the house rule asks for. The brand-logo
fetch had no deadline at all next to a Wikidata hop that allows six seconds, so
a stalled CDN held a request context and a socket for undici's five-minute
default, and its size cap only applied after the bytes were in memory. The
Wikimedia photo proxy beside it had the same shape. And the Amap provider
buffered every answer uncapped and trusted a cast for its arrays, so a `pois`
object rather than a list threw a TypeError that surfaced as a 500 instead of
the OpenStreetMap answer.
2026-09-13 23:49:00 +02:00
Maurice 9deedb80ea fix: receipts, cached places, chat previews and the Google-route tool
Saving an expense twice while its receipt is also attached to a place and a
booking hit the new unique index on (file, expense): the row kept from the
first save was skipped, the next spare link row was adopted into a duplicate
pair, and the constraint threw inside the transaction. The whole edit rolled
back with a 500, every time, so that expense could never be changed again. The
adopt now skips a link the expense already holds, and a receipt named twice in
one save is linked once.

The offline place cache was keyed on the place id alone while it is owned and
evicted per trip, so two trips over the same city took each other's rows: the
second write claimed them, switching the second trip off deleted them for both,
and the first trip's fingerprint still matched so nothing ever refilled them.
Offline search then answered nothing for a trip whose switch read "on". Each
trip keeps its own copies now, and the upgrade clears the fingerprints so the
next sync refills what the rebuild dropped.

The chat composer minted its object URLs inside a state updater, which React
runs twice in development, so one picked image drew two thumbnails and removing
one left a preview for a picture that would not be sent. File and URL are one
record now and every mutator commits a finished list.

import_google_maps_route wrote up to thirty places and their day assignments
under trips:write, which does not imply the places:write that create_place is
gated on, and it was the one non-admin write tool with no demo gate and the
only tool in the repo with no annotations. All three corrected.
2026-09-13 23:49:00 +02:00
Maurice f0e4c9a33d fix: five small defects the audit turned up in the release
An explicit null on a place's duration_minutes advertised a reset it never
performed: the column sat behind COALESCE, so null and absent were the same
thing and the day plan kept budgeting the old stay length. It clears now, and
0 still writes 0.

update_trip_file and link_trip_file never learned the budget_item_id target the
REST routes gained, so a receipt could not be attached to an expense from MCP.
Added to both, along with the cross-trip check that goes with it: adding the
field without it would have opened the hole the same change closed for REST.

The roadtrip preferences PUT dropped the X-Socket-Id it was given, so the tab
that saved a setting received its own change back and spent a second store
commit and a route recompute on an answer it already had. Its two siblings on
the same feature forward it.

A shared journey did not carry the three "this journey does not use that field"
switches, so the phone card read undefined, decided everything was switched on,
and showed mood and weather chips on a journey whose owner had turned them off.

An entry's country flag followed the pin when the entry was edited and not when
the trip sync moved the place underneath it, so a stop dragged across a border
kept the old flag with nothing to ever correct it.

Also: dawarich.helpers.ts held a raw NUL byte as a hash separator, which made
every search tool treat the file as binary and skip it. Same character, written
as an escape.
2026-09-13 23:49:00 +02:00
Maurice 89bae414ff fix(offline): a missing driving-settings row no longer discards the trip bundle
syncTrip read the roadtrip preferences before writing anything and rethrew
everything but a 404. That read has an offline fallback which throws a bare
Error when there is no cached row, which is the state a first sync is in and
the state a logout leaves behind, so the whole downloaded bundle for that trip
was dropped while the run still counted it. Settings reported "N trips stored"
over an empty database, and the trips were gone the moment the connection was.
The read moved to the end and is never fatal now: it belongs to an optional
addon and the addon answers 404 when it is off.

Two things that fed it, from the same trace. A sync run started on
navigator.onLine alone, so work-offline downloaded over the real connection
while every repo read answered from cache. And work-offline itself survived a
logout, in localStorage, while step six of the same logout deleted the database
it reads from, so the next account came up believing it was offline with
nothing to be offline with.

Also in this pass: deleting a hotel booking released its day stop after
deleting the booking row, which now clears the stop's pointer by trigger. The
release goes first, or the stop stands with nothing left to reach it.
2026-09-13 23:48:59 +02:00
Maurice c42cf5477b fix(public-api): a key narrowed away from days stops getting days back through places
Places, notes and reservations hang off days, so asking for one of them implies
the day container. That implication ran ahead of the grant: a key minted with
scopes ['trips','places'] and deliberately no 'days' still received every day's
title and the day's own free-text notes, either by naming places or by naming
nothing at all, since an absent include narrows to the granted sections and
lands back in the same branch. Asking for days by name was a 403 the whole
time, so the two routes disagreed about the same fields.

The grant now reaches the service alongside the narrowed include. The container
is still implied, because the places inside it are useless without their date,
but the day's title and notes only go out to a key that was granted days. A key
with full access is unaffected: it is granted days whether or not it asks.
2026-09-13 23:48:59 +02:00
Maurice 54e5525568 fix(accommodations): a booking that moves carries its stop instead of rebuilding it
Correcting a booking's date deleted the day stop it had mirrored and inserted
a fresh one. A day stop is not just a day and a place: its participants and any
road-trip day boundary hang off its id by ON DELETE CASCADE, and its note, its
hour and its end-of-day flag live in its own columns. All of it went, silently,
for an edit that only meant to move the night one day on. The row is carried
across now, seated by its check-in the same way an insert would be, and the
delete-and-insert is kept only for the case where the target day already holds
that place under a stop of the traveller's own.

Three more holes in the same mirror, found alongside it:

Dropping a night while keeping the place cleared accommodation_id server side
and told nobody. Days hides a stop that carries a booking id, so on every other
screen the place stayed invisible on a day it was standing on.

A night is seated by its check-in, which renumbers the stops around it, and
nothing said so. The day that changed now sends its order along.

Deleting a place cancelled the nights booked there, which takes each night's
partner booking and that booking's expense with it, and announced neither. The
Bookings list kept the reservation and the Costs total kept the money until a
reload, and an expense linked by reservation_id is not one the place-linked
sweep finds.

Also: day_assignments.accommodation_id went in as a bare INTEGER, so shortening
a trip past a booking's last night cascaded the booking away and left the stop
pointing at nothing, hidden from Days for good while the route still drove to
it. Migration 234 clears the ones already out there and adds a trigger, which
SQLite fires even when the booking went down with a cascade. And a duplicated
trip now carries the stop's booking id, the booking's check-in window and a
place's fill_percent, none of which the copy took along.
2026-09-13 23:48:59 +02:00
Maurice 0cc6dcf8aa Merge pull request #2360 from liketrek/fix/oidc-exchange-binding
fix(oidc): bind the SSO auth code to the browser that earned it
2026-09-13 22:09:59 +02:00
Maurice a2585beb13 docs(wiki): the SSO exchange takes a cookie as well as the code 2026-09-13 22:05:54 +02:00
Maurice 803a7cb64a fix(oidc): bind the SSO auth code to the browser that earned it
The callback hands the finished login back as a code in a redirect URL,
and /exchange turned any code into a session. That made the code the
whole credential while it travelled somewhere URLs are readable: browser
history on a shared machine, a referrer, a reverse proxy log. It cuts
both ways. Redeem a code you saw over someone's shoulder and you are
them; or complete a login as yourself, hand the victim's browser your
code, and they are silently signed into your account and write their
next trip into it.

The callback now also sets a one-minute httpOnly cookie holding a random
secret, and /exchange wants the code plus that cookie. Only the browser
that came back from the provider holds both, which is the same shape the
state cookie already gives the callback. The code is burnt on every
attempt, right or wrong, and an unbound code answers exactly like an
unknown one so presenting one confirms nothing.
2026-09-13 22:03:24 +02:00
Maurice 9cb74e22f6 fix(dawarich): a stay's length was sixty times too short
Dawarich's `duration` is minutes. It writes the column as
`duration_minutes = ((ended_at - started_at) / 60).to_i` in
`app/services/visits/create.rb` and serialises it straight through; the merge
service rounds the same way. TREK read it as seconds and divided by sixty
again, so an eighteen-minute stop reached the suggestion panel as "0 min" and
an afternoon somewhere as "2 min".

Found by connecting a real Dawarich 1.14.5 and comparing: a visit spanning
11:00:00 to 11:18:45 reports `duration: 18`.

The span between the two timestamps is what the panel uses now. It agrees with
the arrival and departure shown beside it and it survives whatever unit a
future Dawarich picks; the reported figure only stands in when `ended_at` could
not be parsed, and is read as minutes there too.
2026-09-13 21:38:20 +02:00
Maurice 8eb936f46f fix(test): the places e2e knows the table the delete path reads
Deleting a place cancels the nights booked at it, and that query hits
`day_accommodations`. This file builds its own minimal schema and did not have
the table, so every delete answered 500 and three cases failed. The assignments
e2e was given it for the same reason; this one was missed.

This is the failure that has been keeping Server Coverage red on dev.
2026-09-13 21:25:07 +02:00
Maurice 6e8aa3ab26 fix(dawarich): say why the address could not be reached
The server works out the reason and names it: a rejected certificate, a name
that does not resolve, a timeout, or the SSRF guard refusing a private address
along with the environment variable that would permit it. All of that reaches
the client as `errorDetail`, and the panel dropped it, leaving one sentence that
reads the same whether the host is off or TREK refused on purpose.

A tester could only report that it did not work. The panel shows the reason
under the message now; the toast stays one line, and the vague codes stay vague
so an English sentence from Dawarich does not land on a German install.
2026-09-13 21:19:42 +02:00
Maurice 76f45512d1 feat(admin): the phone's API-keys card, rebuilt like the desktop tab
The desktop tab was reshaped around one idea: a key should be the exception
rather than the thing you are expected to paste. The phone still had the older
shape, where Google wore the "Recommended" chip and the TREK Places API was not
on the screen at all. Two pages, opposite advice, same setting.

So the phone gets the same three comparable blocks in the same order: the TREK
index first, with its own ribbon and a folded panel of what it does and does not
carry, then the two keys under theirs. The Google caveat is said before the
field rather than after it, because somebody about to paste a key should read it
while deciding. The four Google switches moved under the key they spend, folded
away: they are set once and never touched again, and open they were longer than
the rest of the card together. Open-Meteo left the card entirely, as it did on
the desktop, since it takes no key and had the loudest treatment of anything
there.

The provider choice is TREK's own picker now. A native select is drawn by the
operating system and was the one control on the page that did not belong to the
app.

Its description was also describing a TREK that no longer exists: it said one
service answers place search. The index and OpenStreetMap answer every search
and the setting only decides who is asked once they come up empty
(maps.service.ts, keyedProvider). Both that line and the missing-key notice say
so now, in all 23 languages, and the two key hints have short forms for the
narrow screen.
2026-09-13 21:07:55 +02:00
Maurice c2751d9c80 fix(journey): declare the three field flags on updateJourney
The allow-list learned them and the signature did not, so the only callers that
name them are the tests and only `typecheck:tests` saw it. `typecheck` covers
src alone, which is why this was green locally and red in CI.
2026-09-13 20:44:24 +02:00
Maurice f8ce80da32 fix(mobile): the pills and the badge above them on one ground
Both are white at 28 percent, and they did not look it. The pills carried a
backdrop blur of their own, and a second backdrop-filter inside the panel's own
opens a new backdrop root, so the same colour composited darker one row down.
The panel already blurs everything behind it.
2026-09-13 20:41:14 +02:00
Maurice cbc28fad88 fix(journey): a dead translation key, and two cards that spoke differently
`common.errorOccurred` is in no locale. It sat in the reorder failure toast
already and I copied it into three more places, so a failed dismiss, restore or
reorder printed the key at the reader. They all say `common.errorTitle` now.

The suggestion mark on a phone card was the word spelled out, which took a third
of a 136px card and pushed the date off it; it is the same plus the desktop
suggestion card offers, and the card's italic dimmed title carries the rest.
Dismissing a suggestion from the entry sheet has its own full-width row above
the buttons rather than a two-line label wedged between Delete and Cancel.

And two cards that should have been one design: the dashboard's spotlight trip
wrote its facts in capitals on a panel of dark glass, the journey hero wrote
them in sentence case on a gradient with its badge loose in a corner. The
journey card is built the way the trip card is now, and the trip card's three
pills are set like the DAY x OF y badge beside them, a size down and on the
same ground.
2026-09-13 20:32:49 +02:00
Maurice 15aefa449e feat(journey): the marker card, and what testing it turned up
Everything here came out of putting the first commit in front of somebody and
watching them use it.

**The marker card is one card now, and it carries the entry's photos.** Leaflet
showed a one-line label and the GL map a two-line card, so the same marker said
different things depending on a renderer setting nobody connects to journeys.
Both now build from one module: a strip of up to three thumbnails bleeding to
the card's edges, the name, and the place and date as chips. On the GL map the
thumbnails open the entry's photos; the rest of the card still never takes the
pointer, so the map drags from anywhere on it.

**A pin that would not sit still.** Dragging the map left the marker trailing
behind and snapping back at the end. The app's press-feedback rule puts a 180ms
transition on `[role="button"]`, MapLibre and Mapbox give their marker element
exactly that role, and both rewrite its `transform` on every frame of a
movement. Markers are excluded now — every map in TREK, not just this one.

**The phone card, after four rounds of looking at it.** The day is the card's
own edge rather than a bar across its foot, and the ring is inset because the
carousel clips. A card without a photo is opaque: it was a wash of the day's
colour over the map. The country is a real flag rather than two letters —
Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and
the image falls back to the emoji when it cannot be fetched. Mood, weather and
the date share the top edge; the flag sits opposite them.

**The day bar answers to a finger.** Press the date and slide sideways to run
through the days. The bar is still a row of buttons underneath, because that is
the only version of it a keyboard can reach, and each one is 20px tall now
rather than 3.

Also here:

- Videos upload to an entry (issue #2341). The gallery has taken them since
  #823 and an entry could not: everything went through the images-only filter
  and came back 400, reported as "1 of 1 photos failed". Same two-part upload,
  same absence of transcoding, and the editor now previews a clip in a `<video>`
  instead of a broken-image glyph.
- The search box takes the gap the toolbar already had instead of clipping its
  own placeholder.
- Entry cards line up with the day header again: the reorder arrows moved out of
  a column of their own into the feed's padding and appear on hover, and the
  day-coloured ring on the active card is a layer over it — an outline tight
  enough to sit on the card's edge was painting behind it.
- Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence
  asks for it — but it does not have to lie under the carousel.
- The suggestion dismiss and the day-header plus use TREK's own tooltip rather
  than the browser's.
2026-09-13 19:52:14 +02:00
Maurice c2707b7eff feat(journey): the enhancements from discussion #2299
roel-de-vries pitched seventeen changes to Journey. Four were already built or
rest on a misunderstanding, one cannot work as described, and three would take
away more than they give; the rest are here, plus two things the list did not
ask for.

**The entry you write on the move now has a name.** Quick capture hid the title
field outright and offered a note instead, so every entry caught on a phone
arrived nameless and a day's timeline read as a column of identical
placeholders. One line of input; it is the single change in this branch that
users will feel most.

**The map stops yanking.** Stepping through the timeline called flyTo with a
zoom floor of 12, so reading a journey from a country view snapped to street
level on the first scroll and stayed there — every marker filling the screen,
and no sense of where anything was. Both renderers pan now and leave the frame
the reader chose. The initial fitBounds still decides how close a journey opens.

**The phone timeline is photos.** A 64px thumbnail beside three lines of text
spent the card on a story nobody reads at that size and truncated the title
after a few characters. The picture is the card now, with the name and place on
it, the country's flag and the date in the corners, and the day carried as a
colour rather than as a number nobody was reading. Above the carousel, a
segment per day: it says how long the journey is, how far in you are, and takes
you to day nine without swiping past days three to eight.

Everything else, briefly:

- A plus in each day header, starting a draft on that day instead of on today.
- A journey opens on today when today is part of it, and at the beginning when
  it is not — a journey that ended last spring is something you read.
- Suggestions can be waved away one at a time. The row survives so the trip sync
  does not offer the same place again; journey settings brings them all back.
- Mood, weather and the pros/cons list can be switched off per journey. Nothing
  written is erased — the form stops asking, the values stay.
- The weather fills itself in at the desk the way it already did on the phone.
  The date picks the source, so writing up last Tuesday gets last Tuesday's.
- Enter opens the next pro or con, below the one you are in.
- The verdict moved behind the story's fold, and the fold itself came in from
  nine lines to four.
- The external picker lost the count it was printing twice and some padding it
  could not afford inside the editor.
- The phone can reorder entries within a day at last; the desktop arrows finally
  have translated labels.
- Marker labels are off where a carousel already names the entry, and on where
  they are the only name a marker has.
- Search over a journey's titles, stories, places and tags, accent-blind.
- An entry knows which country it happened in, resolved once from its
  coordinates against the bundled boundaries.

Not built, and why: the photos-for-markers idea needs clustering first or thirty
markers become soup, and the active entry's photo is already on screen in the
card below. Removing mood, weather and the verdict outright would take them from
everyone who uses them, so they became switches. `capture` on the file picker
takes the photo library away on a phone, which is why the camera is a second
input; it is hidden on desktop now instead. The suggestion strip in the new-entry
flow would be a third surface over the same data — tapping a suggestion already
opens the editor filled in.

The one gap the list missed and this branch does not close: Journey is the only
domain with no offline path. That belongs in its own change.
2026-09-13 18:45:57 +02:00
Maurice 6edb4462f9 style(collections): the label chip rounded like the rating beside it
Two badges in one row read as one set only if they share a shape. The rating
was a pill, the label a rounded rectangle.
2026-09-13 17:06:35 +02:00
Maurice 27ce4df6f5 style(collections): the place panel as things on their own ground
The labels tried the cover photo and lost: a blue chip on a blue sky is simply not
there, whatever shadow it carries. They sit beside the rating now, both as badges
on one row — two answers about the place, side by side, each legible.

The note takes the full width on a ground of its own. Loose in the column it read
as another paragraph of the panel rather than as something somebody wrote about
this place.
2026-09-13 17:02:17 +02:00
Maurice 1a95157b3a feat(collections): say what a place is without having to click it
Asked for in the feedback: a wall of round photos, and no way to tell which is
which. "Name and displaying any custom rating would be very beneficial to quickly
seeing what is around you that is of interest."

The collections map had hover switched off. The trip map has told you what a
marker is all along, so it is the same card, now shared instead of written a
third time — it already existed twice, once per renderer, and the two had drifted
in how they resolved the category icon.

The card gains a rating, and so does the marker itself: an amber disc on the
corner where a trip's day numbers sit. The two never collide, because a numbered
stop is one already planned into a day and this answers the question asked before
that. Nothing is drawn for an unrated place, which is most of them.

Also from this round: the place panel's labels move up beside the name, where a
label belongs — read three rows down they looked like a property of the rating
above them — and the rating gets a ground of its own so a line of stars stops
reading as another row of text.
2026-09-13 16:49:20 +02:00
Maurice 8fc788fb37 style(collections): the place footer is one row of things you do
Getting directions sat up by the address, in a pill of its own, while editing and
copying sat in the footer as square buttons. All three are things you do WITH the
place, so they belong together and should look like one set. Navigation moves
down beside them, and the footer takes the shape the navigation button already
had.
2026-09-13 16:35:31 +02:00
Maurice de3b2a6ac7 fix(roadtrip): a check-in sets the hour when nothing before it does
Book a night to check in at ten, pin a stop to three in the afternoon behind it,
and the rail said 13:46. True enough as arithmetic — with no hour of its own the
chain works the first stop backwards out of the first one that has one, so three
minus a fourteen minute drive minus an hour standing there — and nothing anybody
asked for.

A door that opens at ten, with nothing ahead of it deciding otherwise, IS the
hour: the day starts there and runs forwards. Reached later it stays what it was,
a floor, and a floor below the arrival changes nothing — which is what keeps a
hotel behind an afternoon stop at its real arrival instead of dragging it back to
its check-in.
2026-09-13 16:22:07 +02:00
Maurice fcee6bd2c5 fix(roadtrip): sort a booked night by its check-in, and clear the lint the cut left
Pinning an afternoon stop sorted that one and left the hotel it was booked around
sitting behind it. The day's auto-sort reads each stop's own hour, and a booked
night has none: nobody types a time into a hotel row, they type a check-in, and
that lives on the booking. It counts now, so the night moves with everything else
instead of staying where it was dropped.

The assignments e2e builds its own tables and had neither the accommodation
column nor day_accommodations, so every create in that file 500'd as soon as the
insert learned to carry a booking id. Both are there now.

And the shared lint, which is stricter than the other two and which I had not run:
three leftovers from taking the check-out out of the chain, none of them used any
more.
2026-09-13 16:19:57 +02:00
Maurice c351a0471c fix(accommodations): a check-in decides where in the day the night sits
The stop went to the end of the day, always, on the reasoning that a night ends
the day. It usually does — but a check-in at eleven says the traveller is at the
desk before whatever they pinned to the afternoon, and putting the stop behind it
built a drive that reaches the hotel at seventeen past five after a sixteen
hundred it was booked around. The order looked like the traveller's and was not;
it was this code's default.

A booking with a check-in now takes the place its hour asks for: ahead of the
first stop pinned later, and still last when nothing is. Editing the check-in
re-seats it, in both directions, read off the chain rather than recomputed —
the index a fresh insert would get is not the index a row already holds.

Stops without an hour of their own are passed over in either direction. Their
place in the chain came from the traveller, not from a clock, and a night has no
business pushing past one.
2026-09-13 16:06:11 +02:00
Maurice 3f6d1e8ed7 fix(accommodations): re-export the module, not the provider inside it
The container refused to boot: Nest will not export a provider that belongs to an
imported module, and the service moved into the domain module in the commit
before. Re-exporting that module gives every importer the same service it always
got, and the wiring test now asserts the shape that actually boots rather than
the one that only typechecks.

Neither tsc nor the unit tests could see this — the module metadata is only read
when a container is built. The e2e harness does build one, and that is what
catches it.
2026-09-13 15:50:58 +02:00
Maurice 46aa0c6b0a fix(map): the base layer switch stays in its corner when a place opens
Opening a place sent it from the bottom left of the map to the top, which reads
as the control moving house rather than as room being made. Nothing was in its
way: the inspector is a card centred between the sidebars and at most 800 wide,
so the corner it was clearing is one the card never reaches.

Both renderers, since the switch is not supposed to move when an instance changes
its map.
2026-09-13 15:41:46 +02:00
Maurice 77a16d6f26 fix(places): deleting a place cancels the nights booked at it
A stay whose place was deleted kept its row with place_id NULL: still drawn in the
day header, still naming a hotel through its partner booking, and pointing
nowhere. Nothing could reach it any more and nothing cleaned it up.

It goes with the place now, through the accommodations domain rather than a
DELETE in the places path, because a stay takes its partner booking, that
booking's expense and the day stop it wrote with it — none of which is the places
domain's business to know. Both delete paths, single and bulk.

The service moves into an AccommodationsDomainModule, the assignments-domain
precedent: the service never touches PlacesService, only the MCP tool that writes
a place and a stay together does, so it can sit in a module PlacesModule is free
to import without closing a loop. AccommodationsModule keeps exporting it, so
every existing consumer is unchanged.
2026-09-13 15:36:02 +02:00
Maurice 7b50692372 fix(roadtrip): a check-in is a door opening, not an appointment
It is the hour a room becomes available. Reaching it later is reaching it; only a
time somebody pinned to a stop can be missed. Read as an anchor it did the exact
opposite of what it means: arrive at 16:14 at a hotel that checks in from 11:00
and the rail reported five hours fourteen late, then read that 11:00 as earlier
than the 15:00 before it, took it for a new day and pushed the hotel onto
tomorrow. The day it came from was left with one stop and dropped out of the list
altogether, so the stop before it appeared to vanish.

The schedule now takes it as a floor: waited for when the drive gets there first,
and otherwise silent. Waiting still prints in ink, because the hour is one
somebody named; arriving late to it prints as computed, because nothing was
decided.

Same shape of mistake as the check-out, from the other side. One says the earliest
you may be somewhere, the other the latest you may still be — neither says when
you drive.
2026-09-13 15:28:48 +02:00
Maurice 62d4cdcc18 fix(accommodations): catch the nights the upgrade itself could not reach
A migration runs while the old container is still answering. A booking written in
those seconds comes from code that knows nothing about the day stop and lands
behind the sweep that would have given it one: on the test instance exactly one
of ten, and invisible to the drive ever after, with no way back short of saving
it again. The sweep runs a second time, which is safe because the same NOT EXISTS
decides it both times.

The other way into that state is closed too. "Remove from day" in the place panel
took the stop and left the booking, which is the very state this whole change
exists to remove — and it was the only control that could produce it after the
upgrade. A stop a booking wrote is no longer offered there, in either direction:
removing it strands the booking, adding one puts the same hotel on the day twice.
The night is removed where it is made, in the day's overnight block or by turning
it back into a pause in road trip mode.
2026-09-13 15:19:33 +02:00
Maurice a89ad06ba5 fix(roadtrip): a night is over in the morning, not two driving days later
With daily travel times set, a stay was spent only against the hours the window
is open. Twenty-four hours then took two of them to run down: a single night
ended on the day after next, and every stop behind it moved with it.

Night time is time. The hours between one window closing and the next opening now
count towards the stay, so a night that starts just after eight is over just after
eight the next morning and the day carries on from there. An eleven-hour visit
from eight is over at seven in the evening rather than saving its last hour for
tomorrow — the traveller is still there after the driving stops, they simply do
not drive on until the window opens again.
2026-09-13 14:42:54 +02:00
Maurice 7197ec1c96 fix(roadtrip): a stay that outlasts the day takes the road with it
Three things the day boundary was swallowing, all from the same stay.

The stop after it stayed on yesterday. It is reached a full day later but at a
later reading on the clock, and the only rule watching for a new day was "the
clock went backwards" — which that never does. The schedule's own count knows,
as long as it is read as a difference: a day whose first arrivals came out
negative is lifted whole, and reading the lifted offsets raw once moved an early
stop to tomorrow while leaving the late one behind. It now crosses over properly,
marked as having set off the day before.

The morning after started at the exact minute the stay ended, as if the road to
the first stop took no time. The carried minute is a departure; the drive from
there is added to it.

And that drive was invisible. It was on the map and in the day's total, but the
rail jumped straight to a stop at 08:11 after a stay that ended at 08:00. It gets
the same band any other drive gets, above the stop it arrives at — except where a
stop crossed over, which already draws that road under its own block.
2026-09-13 14:34:52 +02:00
Maurice 3d201e1ced fix(roadtrip): the check-out leaves the drive, and a long stay carries the clock
A check-out is the LATEST the room has to be handed back, never the earliest
anybody may drive on, so it says nothing the chain can use. It shaped four things
it had no business shaping: the departure the day was built from, the length the
rail printed for a booked night, a day-selector and a clock field in the stop
dialog, and the branch that sent a night's STAY badge to the booking form because
its length was not editable here. All four go. The booking keeps its check-out;
it is a booking detail and lives under Days, where it is entered.

What replaces it is the thing that was always the honest answer: how long you
stand there. That is the traveller's own statement rather than a deadline, and a
stay running past midnight now carries the clock into the morning after — a stop
pinned before it ends keeps its hour and collects the late finding, as it would
from any leg it cannot make. A stay spanning whole days walks the clock forward
over them. An ordinary evening hands on nothing: the night between two days is
not a wait somebody is serving out.

The stay dialog goes to a full day rather than four hours, since it is now the
only place a night is given its hours, and offers eight and twelve as presets.
Every stop answers the same question the same way.

Overlay panels lose the skeleton placeholder. It was drawn in the page flow, so
the first time each dialog was ever opened a pale block flashed under the planner
and never again once its chunk was cached.
2026-09-13 14:23:49 +02:00
Maurice f4809a67d3 Revert "fix(roadtrip): a night booked into the next morning holds that morning"
This reverts commit d4677bdea, and keeps the departure chip's icon.

The premise was wrong. A check-out is the LATEST moment the room has to be
handed back, not the earliest one you may leave: whoever wants to set off at
seven checks out before that or leaves the bags at the desk. Reading it as a
floor turned a perfectly good plan into a finding, and no amount of wording
around that would have made it true.

The chip keeps the door-out mark. That part says what it always said, only in a
symbol that is not a second clock face beside the arrival.
2026-09-13 14:01:18 +02:00
Maurice d4677bdeab fix(roadtrip): a night booked into the next morning holds that morning
Every day was scheduled from nothing, so a booking that runs into the next day
ended at midnight as far as the chain was concerned. Check in at 08:00 and out
at 08:00 the next day, and the rail was happy to put you somewhere else at 07:00
that morning, with no sign that the room was still yours.

The day a check-out falls in now starts at the check-out rather than at nothing.
A floor, never a move: a stop with a clock of its own keeps it and collects the
late finding any leg it cannot make in time would give it, so a plan that says
07:00 still says 07:00 and merely admits it does not add up. A stop with no clock
starts at the check-out instead of having no time at all.

Only that one day. A stay running Monday to Thursday leaves Tuesday and Wednesday
alone, because you sleep there, you do not sit there.

The departure chip takes the door-out mark instead of a clock face. Beside an
arrival time, a second clock read as a second arrival.
2026-09-13 13:23:32 +02:00
Maurice 6845db4cb8 fix(roadtrip): a check-in is a time somebody chose, so print it as one
The rail prints an hour in ink when a person picked it and in grey when the chain
worked it out. Every scheduler anchors on `time ?? checkInTime`, so a booked night
starts its day on the check-in exactly the way a pinned stop does — but two of the
three then asked only about `time` when labelling the result, and printed that
chosen hour grey.

The answer lived in three places and two of them disagreed with the anchor they
had just used, so it is one function now. Nothing but the rail reads the flag.

Only visible since a booked night became a stop of its own; before that no stop
carried a check-in.
2026-09-13 13:10:58 +02:00
Maurice 274dbea069 fix(planner): hide the booked night where the day list is actually drawn
The filter sat on the planner hook's assignments, but the day sidebar is handed
storedAssignments straight from the store, so it never saw it. The hotel was
listed as an ordinary place right under the overnight block naming it, on every
day someone booked a night for.

Filtered in getDayAssignments now, next to the service-stop filter that is there
for the neighbouring reason. That is the list itself, so the reorder and undo
paths keep reading the full day and nothing shifts underneath them. The phone
was never affected: its timeline reads the hook's copy.
2026-09-13 13:04:19 +02:00
Maurice d0d3f455a8 fix(planner): let the day list see which stop a booking wrote
The row shapers name every column they pass on, and accommodation_id was in
none of them. So the field the day list hides on never reached the client, and
the booked night was listed as an ordinary place next to the overnight block
that already named it. The stop was correct in the table the whole time; the
three read paths just dropped it on the way out.

The stamp on the freshly written stop had the same hole from the other side: the
INSERT went in without the booking id and an UPDATE added it afterwards, so the
row handed back in the answer, the one the client that booked the night puts
straight into its store, carried none. It goes in with the INSERT now.

The shared link gains the same filter. It has no road trip view to need the stop
and already shows the booking as its own chip on the day, so the row was only
ever the same hotel twice.
2026-09-13 12:59:19 +02:00
Maurice e3471e2dc8 fix(reservations): a night booked on the form belongs on the drive too
The booking form wrote the stay row itself, so it was the one door that left the
day stop out. A hotel entered there stayed invisible to road trip mode, which is
the same complaint as before with a different way in: enter the place twice or it
is not on the route.

It writes the stop now, through AccommodationsService rather than a second copy of
its SQL. The permission argument for not doing so does not hold: the night already
shows in the day header the moment the booking is saved, and the stop is that same
night in the other view, not a second edit to the plan.

Two holes go with it. Moving the booking left the stop on a day nobody sleeps there
any more, and deleting it left the stop behind entirely — in both cases still
carrying the booking's id, which is what the day list hides on, so nobody could
reach it to remove it. The stop now follows the stay and goes with it, and only
once the trip_id guard has confirmed the stay was this trip's to delete.
2026-09-13 12:38:21 +02:00
Maurice eff4605ef3 test(accommodations): cover the paths the day stop added, and the ratchet fell on
Six lines and four functions the mirror brought in were never entered by a test,
which is what took the domain under its coverage floor.

The keepStop branch of a delete, the plugin surface's own update, and the
transaction that has to roll a place and its stay back together. Plus every
sender a surface hands announceMirror: REST and the three plugin methods build
one each, and a fixture that swallowed the call let all four go unexercised --
the one link between a stay write and the sockets everyone else is watching.
2026-09-13 12:12:38 +02:00
Maurice edf0538260 fix(roadtrip): keep a booked night out of the day list, and off the traveller's stops
Three things the first pass got wrong.

A stop a booking puts on the day is for road trip mode, which needs the
hotel in the driving chain. The day planner already shows that booking as
its own overnight block, so the row there would be the same hotel twice.
It is hidden in Days whatever the service-stop switch says, and the switch
goes back to meaning what it says: the petrol stations the traveller added.

Cancelling a night from the road trip popup took the stop with it. That
control means "this is a pause now, not a night", and the stop sits
mid-drive where re-adding it would land it at the end of the day. The
booking now hands the stop over instead of deleting it.

Moving a booking whose stop belongs to the traveller put a second one on
the new day and left the first behind, which is the duplicate this change
exists to remove. Only a booking's own stop moves now. Stays booked before
this shipped get theirs from the migration, not from the next edit they
happen to receive.
2026-09-13 01:42:52 +02:00
Maurice 462a208fdf fix(planner): drop the hotel bookend leg when the hotel is the edge stop
The day's accommodation is drawn as a leg to the first and from the last
waypoint. Now that a booked night puts its hotel on the check-in day as a stop
of its own, that last waypoint IS the hotel on most days, and the leg drawn to
it is a round trip of zero kilometres: in the map line, in the sidebar's leg
list and in every exported directions link, which carried the hotel twice in a
row. The defect already existed for a night booked in the road trip; the change
above would have made it the normal case.

Both bookend rules now decline a leg whose edge stop sits at the hotel's own
coordinates, which covers the map, the sidebar, the phone route and both export
links from one place. Compared by coordinates rather than ids, because the
bookend comes off the stay row and the waypoint off the assignment.
2026-09-13 01:13:41 +02:00
Maurice 5e0245046d feat(planner): show the stop a booked night writes without a reload
The server announces the day stop over the socket like any other assignment,
but the socket deliberately skips the session that sent the request, so the one
person who could not see their new stop was the one who had just booked the
night. That looks exactly like the bug it fixes.

The stay writes hand their answer to one applier, which folds the stop into the
store through the same path the socket uses, so the IndexedDB write-through
happens either way and the dedupe by assignment id comes for free. Three
surfaces call it: the day-detail picker, its edit branch and the phone sheet.
The road-trip flow does not, because it writes its own assignment before the
stay and there is never a mirrored stop to apply.
2026-09-13 01:13:33 +02:00
Maurice f0e1da2d81 feat(accommodations): put a booked night on its check-in day
A road-trip stop is a day assignment: the rail, the server-side plan and the
map all build their stops from day_assignments and only look the stay up
afterwards, to hang check-in and check-out on one. A stay written without an
assignment was therefore invisible to every routing surface, so a hotel entered
in the day planner never reached the road-trip view and had to be entered a
second time as an ordinary place. The road-trip side has always written both
halves in one go; this is the day planner catching up, in the domain that
writes the stay.

Booking a night now also assigns its place to the check-in day, inside the
transaction that already writes the stay and its partner hotel reservation, and
types the place as a hotel stop when it carries no type of its own, so it reads
exactly like a night booked in the road trip. Only the check-in day gets a
stop; the later nights ride on the stay row. A place already planned for that
day gets no second stop and the booking claims none, so cancelling it cannot
delete a stop somebody placed by hand. Moving the booking moves its own stop,
deleting it takes its own stop back, and a trip re-date carries it along with
the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the
same write and announce the same events; the answers carry the stop too,
because the broadcast skips the socket that sent the request.

New nullable day_assignments.accommodation_id marks a stop as a booking's own.
It stays NULL for every existing row on purpose: everything planned before this
was planned by hand, and no backfill rewrites a finished trip. A stay booked
earlier picks up its stop the next time somebody saves it.

The booking form keeps writing stays on its own: that surface is gated on
reservation_edit and the day stop is day_edit work.
2026-09-13 01:13:25 +02:00
Maurice 3ab5ddaacc Merge pull request #2349 from liketrek/feat/dawarich-integration
feat: Dawarich integration
2026-09-13 00:05:05 +02:00
Maurice 8a28d4dd4a test(server): stop racing supertest's own listener in the scope e2e
Three cases fired their reads through `Promise.all`. The Nest app under
test is never told to listen, so supertest binds an ephemeral port on the
first request; four of those at once race four `listen(0)` calls on the
same server and the losers come back as ECONNRESET. It held here and
failed on a loaded runner. The reads run one after the other now, which
is all they ever needed.
2026-09-12 23:55:22 +02:00
Maurice 105bba2b95 docs(wiki): describe the Dawarich integration
What it reads, what it never writes, what happens to the recorded
positions, and how the connection, the suggestions, the Atlas marks and
the wish list behave from the reader's side.
2026-09-12 23:37:13 +02:00
Maurice 5b4a7e82ff feat(client): show Dawarich stays, routes and marks where they belong
The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
2026-09-12 23:37:05 +02:00
Maurice 5678eed55c fix(client): use TREK's own controls for tooltips and journey dates
Three small things noticed while working nearby, none of them Dawarich:

The map's layer switcher showed the browser's own tooltip while every
control beside it showed TREK's, so hovering along the row produced two
different boxes in two different styles.

The journey entry editor mixed TREK's date picker with a native time
input, and the date, which is the longer of the two, had the narrower
column: "12. Sept. 2026" wrapped while the clock sat half empty. The row
is split in the date's favour now and the picker is disabled together
with the rest of the form instead of staying live behind it.

On the phone's journey, the dock's "+" now turns into an upload button
while the Gallery tab is open, which is what that tab's one big action
actually is.
2026-09-12 23:36:49 +02:00
Maurice 218d8baf0a feat(server): read visits and recorded routes from Dawarich
A new `integrations/dawarich` domain behind its own addon. Each reader
connects their own instance; the API key is encrypted at rest and never
returned. Nothing is written back to Dawarich, and no raw position is
kept in TREK: the recorded route is fetched per request for the window
being looked at and thrown away again.

What it produces are suggestions, never facts. A recorded stay becomes a
proposed place or journal entry, a country or city becomes a proposed
Atlas mark, and a stay near a wish becomes a proposed tick-off; each one
waits for the reader to confirm it. Identity is (user, Dawarich visit),
so a second sync neither duplicates nor overwrites what was accepted,
and deleting the result puts the suggestion back on the list.

The sync runs as a job on the central scheduler, one user at a time,
guarded against overlapping runs.
2026-09-12 23:36:49 +02:00
Maurice dad1316172 feat(api): let public API keys be scoped to single areas
A key could only ever read everything a user owns. When a second service
reads TREK on that user's behalf, that is more than it needs: Dawarich
wants trips, days and places, not notes, bookings or the wish list.

Keys can now be minted with an explicit list of areas, checked in the
guard and shown when the key is listed. Keys that existed before this
keep full read access, so nothing that works today stops working. A key
marked as limited whose list cannot be read grants nothing rather than
everything.
2026-09-12 23:36:09 +02:00
Maurice 906eef4349 feat(shared): add Dawarich contracts
Zod schemas for the Dawarich integration: connection settings, the
suggestion list and its accept payloads, the recorded-track window and
the Atlas country/city proposals. Places gain an optional `source` so a
place that came out of a recording can say so.
2026-09-12 23:36:08 +02:00
Maurice b010305c0d Merge pull request #2348 from liketrek/fix/collab-links-polish
feat(collab): make the links panel look like the rest of the collab tabs
2026-09-12 19:44:20 +02:00
Maurice 3529b72393 feat(collab): give links the header and empty state its siblings have
The tab was built to its own pattern: a bold title where the other four
carry a small uppercase one, a counter instead of the add button, the add
form as a light block sitting on top of the list, and a grey line of text
where every other empty panel shows the mascot. Side by side with Notes it
read as a different app.

It now uses the poll panel's shell: the same header, the same pill button
top right, the same modal for the form, and the mascot holding a chain.

The load effect depended on the toast and translation objects, both of
which are new on every render, so the list refetched constantly and a link
that had just been added was dropped again by the response in flight. It
depends on the trip alone now, and reads the other two through refs.
2026-09-12 19:37:07 +02:00
Maurice dd5b45ffda feat(collab): a chain for the mascot to hold on an empty links panel 2026-09-12 19:37:07 +02:00
Maurice 11b69ae815 test(places): give the hand-built places tables the Amap id column 2026-09-12 18:07:39 +02:00
Maurice 79095823e5 chore(maps): drop the attribution import the tile helper replaced 2026-09-12 18:03:11 +02:00
Maurice f2bd116044 docs(maps): an Amap link needs a coordinate
A bare amap.com/place page carries an id and nothing else, and turning that
id into a location would need a keyed detail lookup the URL resolver has no
user for. It answers 400, which AMAP-084 pins. The wiki offered the link as
paste-able and the parseAmapUrl comment still promised the lookup, so both
now say what the code does.

The provider header also claimed photoRefs/photoBytes were deliberately
left out, naming two methods the places-provider interface no longer has.
The reason is unchanged: an Amap picture comes without a licence statement
we could show, so an Amap place gets its picture from Wikimedia.
2026-09-12 18:03:11 +02:00
Maurice ba535e5aab fix(maps): ask Amap only where Amap can answer
With Amap in the keyed slot every reverse lookup went to Amap first,
worldwide. Outside its box that buys a round trip and an empty answer
before Nominatim is asked anyway, which is latency on every right click in
Lisbon. The client side of the same question already filters by the box
(placeNavigation.ts); isOutsideChina is the shared helper both use.

The two fixtures in the file that were cast to any now carry a type: the
key row and the fetch spy whose calls the reverse case reads back.
2026-09-12 18:03:11 +02:00
Maurice aa0d62f063 fix(admin): read key presence from the app config, not the form
The place search provider block now renders on a managed install too, and
there the key fields are empty by design: the operator owns the credentials
and user-profile never sends them. An operator key set through the
environment is invisible in the fields either way. The warning behind the
provider select read those fields, so it told every managed admin that
search "falls back to OpenStreetMap" while their key was answering.

app-config already carries the answer as has_maps_key, and it resolves the
same chain the search itself uses, environment included. has_amap_key sits
beside it, so both come into the store at boot and both follow a save.
2026-09-12 18:03:11 +02:00
Maurice 3e8ab22836 docs(maps): Amap beside the TREK index
The index and OpenStreetMap answer every search; the provider choice
only decides who fills the keyed slot next to them. Rows carry a source
mark, a place keeps its Amap id, and the journey map shares the shifted
projection.
2026-09-12 18:03:11 +02:00
Maurice 2332d45d01 i18n(maps): Amap strings for Catalan, Swedish and Vietnamese 2026-09-12 18:03:11 +02:00
Maurice 2fd5b6b00a fix(admin): keep the place search provider reachable on a managed install
The API keys card was hidden wholesale on a managed install, which took
the provider choice with it although the server leaves places_provider
to the admin there. The card stays; the keys, their Save button and the
Google switches fold away, on the phone shell now too. The mobile
warning uses the pending token instead of an undefined one.

The desktop tests find a key input through its labelled toggle rather
than by position, which is where the accessible name sits.
2026-09-12 18:03:11 +02:00
Maurice f120266f9e fix(pwa): cache Amap tiles like the other raster presets
Without a Workbox rule for is.autonavi.com the offline pre-download
fetched the tiles and stored none of them, the hole #2180 closed for the
OpenStreetMap DE and Stadia presets.
2026-09-12 18:03:10 +02:00
Maurice fdb993de62 fix(maps): project the journey map like the planner on a GCJ-02 basemap
The journey map read the same tile setting as the planner but built its
Leaflet map without the shifted CRS, so an Amap basemap put every entry a
few hundred metres off. A change of datum rebuilds the map, since Leaflet
cannot swap a CRS after construction; every other template change still
retiles in place.
2026-09-12 18:03:10 +02:00
Maurice 84c277993c feat(maps): show where an Amap hit came from, and keep its id
An Amap row now carries the same source mark the TREK index, OpenStreetMap
and Google rows get, named per locale because 高德地图 is the name it goes
by where it matters. The "search is on OpenStreetMap" notices and the
key hint in the details column are gone with the keys dev had already
removed, and the client-side provider mirror with them.

amap_poi_id travels with a picked result into the form, the details
column and the shadow log, so a saved place keeps the id it was found
by. A pasted Amap link goes to the resolver like a Google one.
2026-09-12 18:03:10 +02:00
Maurice 43a6c30325 feat(places): remember the Amap id a place was found by
The provider produced amap_poi_id, but nothing stored it, so a saved
Amap place could never be reopened against Amap. The column now travels
with google_place_id and osm_id through the shared contract, the place
insert and update, duplicate matching, the assignment projections, the
trip copy and the three place MCP tools.
2026-09-12 18:03:10 +02:00
Maurice 1944bdf0a5 feat(maps): let Amap take the keyed provider slot beside the TREK index
The rebase kept maps.service.ts as dev has it: the TREK index and
OpenStreetMap answer every search first, and a keyed provider only fills
what they leave empty. Amap now sits in that slot next to Google instead
of behind an extracted Google provider, which is dropped.

places_provider (auto | google | amap | openstreetmap) decides which keyed
provider holds the slot; auto keeps whatever the install already used.
An amap: id opens against the Amap key regardless of the setting, takes
the plain details lookup on expand, never reaches Google's photo call,
and a pasted Amap link resolves through parseAmapUrl. Reverse geocoding
asks Amap at the instance key before Nominatim.

The migration carries users.amap_api_key, which the rebase had left at a
stale array position, and places.amap_poi_id for the next commit.
2026-09-12 18:03:10 +02:00
A1phaN 57eac32f9f feat(map): amap as map api provider and display template 2026-09-12 18:02:57 +02:00
Maurice a066c94ec5 fix(costs): tell the phone when the settlement did not load
The mobile Costs tab swallowed a failed settlement request, and the final
budget card then filled every traveler's row from the empty fallback: "0.00"
for everyone, which reads as "the trip cost nobody anything". The desktop
panel already keeps a settlementError flag and prints its notice instead of a
number; the mobile tab now does the same in the final budget card.
2026-09-12 17:33:15 +02:00
Maurice 9307025ed0 fix(costs): derive the final budget breakdown on the server, in the same cents
The three figures under a traveler's final budget came from the server, netted
in integer trip cents at the rate frozen on each expense. The rows listed under
them did not: both shells converted the expense list again with whatever live
rate they had, and with none loaded yet (first render, offline) the raw foreign
amount went out under the display currency's symbol. On any trip with a
foreign-currency expense the rows could not add up to the line above them.

The settlement response now carries the rows with the figure: `sources` on each
finalBudgets entry lists what the person fronted per expense, the recorded
transfers on their side and the open flows, in display cents. They are filled
by the same two loops that fill the totals, so an expense with no split members
stays out of them as it stays out of the ledger, and each list is spread over its
figure's already allocated cents with the same largest-remainder split, so the
sum is exact in every display currency. The shells print those cents and only
resolve names; the rows carry the sign of the line they sit under.
2026-09-12 17:33:15 +02:00
Maurice 66761d57bb i18n(costs): name the reimbursement line for both sides
"Reimbursements received" was written from the receiver's chair: the traveler who sent a payment saw it as a plus under a label claiming they had received something. The line is net of both directions, so its label now says so in every locale.
2026-09-12 17:33:15 +02:00
RemEx e0c9a8d1b6 feat(costs): show what the trip finally costs each participant
The settle-up view says who owes whom, but nothing on the Costs screen
answers "what did the trip cost me": the balances move every time a
payment is recorded, and the tiles only describe the signed-in user.

The settlement response now carries finalBudgets, one row per participant
in the ledger: expenses (what they fronted on split expenses), reimbursed
(recorded transfers, received minus sent), pending (their current
balance) and final = expenses - reimbursed - pending.

- Computed in calculateSettlement from the same integer trip cents as the
  balances, filled by the same loops, so the breakdown can never
  contradict the balance printed next to it. Each component is converted
  to the display currency as its own set and the final is subtracted in
  display cents, so the three lines add up to the total in any currency.
- A "Final budget" card on desktop, the narrow desktop layout and the
  mobile Costs tab: one amount per traveler, and a tap opens the three
  lines plus the expenses, transfers and open flows behind them. The
  filtering lives in CostsPanel.helpers so both shells share it.
- Contract in shared (budgetParticipantFinalSchema); the MCP
  get_settlement_summary description mentions the new field it returns.
- Four new costs.* keys, translated in every locale.
2026-09-12 17:33:15 +02:00
Maurice a635e31bd4 test(db): pin the roadtrip preferences replay to its own schema version 2026-09-12 17:33:04 +02:00
Maurice bc6d804af6 docs(budget): say what settled_at does when it is left out 2026-09-12 17:33:04 +02:00
Maurice 259a8c6b4d test(budget): settled_at through the HTTP pipe
The e2e settlement round trip now sends settled_at on create and update
and reads it back from the column, including a PUT that clears it to
NULL. The service test covers the clear branch for null and the empty
string the date picker sends.
2026-09-12 17:33:04 +02:00
Maurice 953720c874 fix(client): clamp a flipped date picker to the viewport
Anchoring the flipped popup to the trigger's top edge dropped the old
vertical clamp: with a short viewport the popup's top edge left the screen
and the month header became unreachable. The popup now flips up only when
there is room above; otherwise it stays below and is clamped so its top
edge never leaves the viewport.
2026-09-12 17:33:04 +02:00
Maurice aa848cc6b1 fix(budget): keep a payment's currency when it is edited on a phone
The mobile payment sheet always sent the display currency, while it seeded
the amount raw in the currency the payment was recorded in. Correcting only
the day of a 1000 JPY payment turned it into 1000 EUR and froze a fresh FX
rate for it. The sheet now carries its own currency, seeded from the
payment (or the display currency for a new one) with the same picker the
expense sheet has, and the payment row shows the recorded amount next to
the converted one when they differ, like the desktop row does.

The day is now required in both payment forms. Cleared, the server stored
NULL and the ledger quietly fell back to the day the payment was recorded.
2026-09-12 17:33:04 +02:00
RemEx 3dd6674d3f feat(budget): let a recorded payment be edited from mobile Costs too
Editing/undoing a settle-up payment was desktop-only — the mobile ledger
row rendered it read-only, with a comment explaining the gap was
deliberate for now. Give it the same edit/undo affordance the expense row
already has: edit reopens AddPaymentSheet pre-filled (now doubling as an
edit form, same as desktop's SettlementModal), undo deletes it outright
with no confirmation, mirroring CostsPanel.tsx's undoSettlement.
2026-09-12 17:33:04 +02:00
RemEx bef11c5222 fix(client): keep a flipped date-picker popup anchored to its trigger
CustomDatePicker flipped above a trigger by subtracting a fixed worst-case
height (360) from its top, regardless of the popup's real rendered height.
When that guess overshot — a short calendar view, or a form with little
room, like the settle-up modal — the gap left the popup floating away from
its trigger and over unrelated fields instead of sitting right above it
(#2334, found while testing the settle-up date field).

Anchor the flipped case from the trigger's own top edge via `bottom`
instead, the same technique useAnchoredPosition already uses for other
popovers, so the popup is always flush against its trigger no matter its
actual height.
2026-09-12 17:33:04 +02:00
RemEx 3a8ae6334c test(budget): match applySettlementUpdate's new settled_at SQL args
The unit test mocking db.run for applySettlementUpdate asserted the exact
positional argument list, which the settled_at column added two more
positions to (its CASE-WHEN flag and value, mirroring currency/exchange_rate).
2026-09-12 17:33:04 +02:00
RemEx bd46089450 feat(budget): make a settle-up payment's date editable
The settle-up form only exposed from/to/amount and silently dated the
payment by created_at, unlike a regular expense which has an editable
date. Add a settled_at column (independent of created_at, same split as
expense_date) through the schema, migration, service, REST and MCP
surfaces, and the desktop + mobile forms; the ledger groups a payment by
settled_at, falling back to created_at for rows recorded before the
column existed.
2026-09-12 17:33:03 +02:00
Maurice 76e182f44e sort route avoidance classes explicitly 2026-09-12 17:26:38 +02:00
Maurice 51626e8184 make roadtrip availability checks explicit 2026-09-12 16:46:22 +02:00
Maurice bc04072dc9 keep overnight checkout planning after a later arrival 2026-09-12 16:22:11 +02:00
Maurice bc15036cfb cover roadtrip MCP authorization and search filters 2026-09-12 16:07:36 +02:00
Maurice 0c78462567 show only planned places on the roadtrip map 2026-09-12 16:05:02 +02:00
Maurice 4f1d0aa210 adjust map clustering and refresh system sqlite 2026-09-12 16:02:01 +02:00
Maurice 465f67ae05 Merge pull request #2334 from liketrek/codex/roadtrip-day-boundaries
Add configurable Roadtrip day endings and station clusters
2026-09-12 15:52:25 +02:00
Maurice 8b99188fc8 fix roadtrip regression test fixtures 2026-09-12 15:47:57 +02:00
Maurice ac57321840 merge dev and reconcile roadtrip planning 2026-09-12 15:45:34 +02:00
Maurice b10b02c685 finish overnight stops and cross-day range warnings 2026-09-12 15:37:09 +02:00
Konstantinos Thermos 359551653f Merge pull request #2319 from liketrek/feat/trip-route-overview
feat(maps): draw every travel day's route at once, with the trip total
2026-09-12 14:49:44 +03:00
Maurice f56e7a161e add open charging data and polish roadtrip warnings 2026-09-12 13:25:15 +02:00
Maurice 228e6a13a9 fix(admin): say what the local model actually gets 2026-09-12 13:07:08 +02:00
RemEx b58771fd4c fix(admin): recommend the local model that also reads images
The one model the addon offers to pull was Qwen3 8B, which is text-only, so a
scanned page or a photographed document reached it as no text at all and came
back empty. Qwen3.5 4B is multimodal at a third of the size: one download
covers every kind of document instead of leaving the operator to work out that
the recommended model cannot read half of them.

The list stays one entry long. A self-hosted host needs a model, not a menu,
and any other id can still be typed in or picked from what Ollama already has.
2026-09-12 13:07:08 +02:00
Luffy e8431d6426 fix: close file picker on outside click and modal dismiss 2026-09-12 12:55:01 +02:00
Maurice 41b18f970c feat(vacay): add manual school holiday calendars 2026-09-12 12:51:27 +02:00
Maurice ff5fa55fad add Google Maps route import and plugin corridor search 2026-09-12 12:24:33 +02:00
Maurice af45b0ca74 add roadtrip warning areas and tidy driving settings 2026-09-12 11:23:09 +02:00
Maurice ec281e5974 fix(roadtrip): shorten day-ending settings 2026-09-12 00:34:12 +02:00
Maurice b773faff70 feat(roadtrip): share trip preferences and improve service stops 2026-09-12 00:24:45 +02:00
Maurice dd6ad31ab9 fix(roadtrip): keep day-ending labels draggable after icon changes 2026-09-11 23:30:38 +02:00
Maurice fccc55732b feat(roadtrip): plan and edit trips through MCP without a browser 2026-09-11 23:25:06 +02:00
Maurice 491cfccc56 feat(roadtrip): add daily scheduling and configurable day endings 2026-09-11 22:28:03 +02:00
Maurice 4f8605b529 merge dependency maintenance #2300 into dev 2026-09-11 15:21:10 +02:00
Maurice 50839f8cc1 copy the map sanitizer patch before Docker installs dependencies 2026-09-11 15:02:06 +02:00
Maurice 8ee2b6ae4c merge dependency maintenance #2322 into dev 2026-09-11 15:01:15 +02:00
Maurice 3e37a708c7 merge dependency maintenance #2324 into dev 2026-09-11 15:01:01 +02:00
Maurice d8f2dc755d merge dependency maintenance #2323 into dev 2026-09-11 15:00:47 +02:00
Maurice 134fcbb424 merge dependency maintenance #2314 into dev 2026-09-11 15:00:35 +02:00
Maurice 2c06c9904b feat(collections): a list as a file, so it can leave the instance (#2198)
Sharing a list works between people on the same TREK. This is the other
case: giving one to somebody who runs their own, or keeping a copy. Export
sits beside Edit and Share in the list header and downloads the open list;
Import sits beside New list in the rail, reads a file, shows what is in it
and makes a new list of it. Desktop only, which the route already decides.

What travels is what somebody wrote down: the list's name, colour and
description, its labels, and each place with its address, coordinates,
notes, status, contact details, category name and labels. A file is dull
JSON on purpose, so it can be read and edited by a person.

What does not travel is everything that only means something on the instance
it came from. Ids of every kind, since the receiver issues its own and a
category crosses over by name. Star ratings and members, because a rating is
an opinion somebody gave the people on that list. Provenance into trips,
which points at rows the receiver cannot see. And an image path under
/uploads, which resolves on the sender's server: only an absolute https URL
survives.

A file is a stranger's JSON and is read as one. It is parsed in the browser
that chose it and never uploaded; the parsed list goes through the same
contract on both sides; and a link that is not http(s) arrives as nothing
rather than as an anchor. Strict where it must refuse, forgiving where it
can drop: a file that does not say what it is never reaches the importer,
while one unreadable place inside it is skipped and counted, and one bad
field on a good place costs that field alone.

An import is always a new list. Importing into an existing one would mean
deciding what happens to a place that is already there, and every answer to
that is a merge nobody asked for.
2026-09-11 14:58:34 +02:00
Maurice 67dd43bc8a fix(maps): backport attribution sanitizer fix without dropping WebGL1 2026-09-11 14:55:37 +02:00
Maurice f5ef14a05f build(deps-dev): update vitest and coverage together 2026-09-11 14:35:51 +02:00
Maurice 7eba81dfd8 build(deps-dev): update sharp with its musl bindings 2026-09-11 14:35:50 +02:00
Maurice edb9a55099 build(deps-dev): refresh baseline browser data 2026-09-11 14:35:50 +02:00
Maurice 74a0825f5a build(deps): update hono to 4.13.7 2026-09-11 14:34:33 +02:00
892 changed files with 63631 additions and 6633 deletions
+2 -1
View File
@@ -20,6 +20,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
@@ -53,7 +54,7 @@ COPY server/package.json ./server/
# would copy up every inode it touches and duplicate the whole tree in the image.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
+8 -6
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -26,7 +26,9 @@
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
@@ -49,7 +51,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6",
@@ -79,7 +81,7 @@
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
@@ -94,12 +96,12 @@
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup-plugin-visualizer": "^7.0.1",
"sharp": "^0.35.0",
"sharp": "^0.35.4",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
"vitest": "^4.1.11"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-tower-control-icon lucide-tower-control">
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
<path d="M8 13v9" />
<path d="M16 22v-9" />
<path d="m9 6 1 7" />
<path d="m15 6-1 7" />
<path d="M12 6V2" />
<path d="M13 2h-2" />
</svg>

After

Width:  |  Height:  |  Size: 495 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+34
View File
@@ -0,0 +1,34 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
const require = createRequire(import.meta.url)
const packagePath = require.resolve('maplibre-gl/package.json')
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
const loops = [
['of elem.attributes)', 'of Array.from(elem.attributes))'],
['of e.attributes)', 'of Array.from(e.attributes))'],
]
for (const filename of [
'src/util/dom.ts',
'dist/maplibre-gl.js',
'dist/maplibre-gl-dev.js',
'dist/maplibre-gl-csp.js',
'dist/maplibre-gl-csp-dev.js',
]) {
const target = join(dirname(packagePath), filename)
const code = readFileSync(target, 'utf8')
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
const [before, after] = loop
if (code.split(before).length === 1 && code.split(after).length === 2) continue
if (code.split(before).length !== 2 || code.includes(after)) {
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
}
writeFileSync(target, code.replace(before, after))
}
+5 -2
View File
@@ -24,6 +24,7 @@ import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
@@ -283,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -300,13 +301,14 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; 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> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
@@ -364,6 +366,7 @@ export default function App() {
const { settings } = useSettingsStore()
useInAppNotificationListener()
useRoadtripPreferencesSync()
useEffect(() => {
if (isAuthenticated) {
+7
View File
@@ -0,0 +1,7 @@
import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared'
import { apiClient } from './client'
export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) {
const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body)
return assignmentSchema.parse(saved.data.assignment)
}
+14 -4
View File
@@ -21,6 +21,7 @@ import {
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type ApiTokenCreateRequest, type PublicApiScope,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
@@ -342,7 +343,10 @@ export const authApi = {
// and a key of the wrong kind is refused like one that does not exist.
apiKeys: {
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
// `scopes` narrows what the key may read (#2279). Omitted means everything,
// which is what every key minted before scopes existed still does.
create: (name: string, scopes?: PublicApiScope[]) =>
apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
},
passkey: {
@@ -999,6 +1003,9 @@ export const journeyApi = {
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
/** A clip on one entry: the video plus the poster frame the browser grabbed (issue #2341). */
uploadEntryVideo: (entryId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/entries/${entryId}/video`, formData, opts),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
@@ -1018,6 +1025,9 @@ export const journeyApi = {
// Preferences
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
/** Bring every waved-away trip suggestion back. Answers with how many returned. */
restoreSuggestions: (id: number): Promise<{ restored: number }> =>
apiClient.post(`/journeys/${id}/suggestions/restore`).then(r => r.data),
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
@@ -1327,8 +1337,8 @@ export const budgetApi = {
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -1424,7 +1434,7 @@ export const accommodationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
delete: (tripId: number | string, id: number, opts?: { keepStop?: boolean }) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`, opts?.keepStop ? { params: { keepStop: 'true' } } : undefined).then(r => r.data),
}
export const dayNotesApi = {
+9
View File
@@ -10,6 +10,9 @@ import type {
CollectionSavePlaceRequest,
CollectionSaveFromTripRequest,
CollectionImportablesResponse,
CollectionFile,
CollectionImportRequest,
CollectionImportResult,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
@@ -53,6 +56,12 @@ export const collectionsApi = {
ax.get(base).then((r: AxiosResponse) => r.data),
get: (id: number): Promise<CollectionDetailResponse> =>
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
// Export / import as a file (#2198). The export body IS the file; the import
// sends the parsed file back through the same contract.
exportFile: (id: number): Promise<CollectionFile> =>
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
// Both answer with the bare collection, not a { collection } envelope — the
// controller returns the service's `Collection` straight through, and the e2e
// suite pins that shape. Declaring the envelope made createCollection() resolve
+459
View File
@@ -0,0 +1,459 @@
/**
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041
* for how long the browser waits.
*
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
* goes into the body or the query string. There is no logic in it to get wrong,
* which is exactly why a mistake here is so quiet: a wrong path or a dropped
* query parameter does not throw, it hands the layer above an empty answer, and
* the panel three levels up simply shows nothing. Every method is therefore
* driven through MSW and its verb, path and payload pinned.
*
* Three things here matter more than the URL bookkeeping:
*
* - **`offset`.** Recorded points are bare instants, so the reader's own UTC
* offset is the one fact the server cannot derive. Both track calls send it,
* and they send it negated: `getTimezoneOffset()` counts minutes *behind*
* local time, the wire wants minutes *ahead* of UTC. Were it to stop being
* sent, or to be sent unsigned, an evening east of UTC would land on the
* previous day of the trip and nothing would fail anywhere. The line would
* just be drawn on the wrong day, which is the whole reason the parameter
* exists. Hence two zones rather than one: a single European fixture cannot
* tell a negation from a magnitude.
* - **The failure reaches the caller intact.** `useDawarichConnection` and
* `useDawarichSuggestions` both translate `err.response.data.code` into the
* reader's language. A `.catch()` added anywhere in this file would turn
* every upstream failure into a silent success with an undefined body.
* - **An abort really cancels.** The track overlay issues a fresh request
* whenever the looked-at window changes and aborts the previous one; a call
* that resolved anyway would repaint the map with the older answer.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich'
import apiClient from './client'
import type {
DawarichAtlasSuggestions,
DawarichBucketScan,
DawarichConnection,
DawarichSuggestion,
DawarichSuggestionList,
DawarichTrack,
} from '@trek/shared'
const BASE = '/api/integrations/dawarich'
let requestUrl = ''
let requestBody: unknown
/** Records the url and the parsed JSON body of the intercepted request, then answers with `data`. */
function record<T extends JsonBodyType>(data: T) {
return async ({ request }: { request: Request }) => {
requestUrl = request.url
const text = await request.text()
requestBody = text ? JSON.parse(text) : undefined
return HttpResponse.json(data)
}
}
function query(): URLSearchParams {
return new URL(requestUrl).searchParams
}
const connection: DawarichConnection = {
url: 'https://dawarich.example',
apiKeyMasked: '••••••••',
allowInsecureTls: false,
syncEnabled: true,
connected: true,
lastSyncAt: '2026-09-13T21:00:00Z',
lastSyncState: 'ok',
lastSyncError: null,
capabilities: null,
}
const suggestion: DawarichSuggestion = {
id: 41,
sourceVisitId: 'v-41',
tripId: 7,
tripTitle: 'Koeln',
name: 'Cafe Reichard',
lat: 50.94,
lng: 6.96,
startedAt: '2026-09-10T10:15:00+02:00',
endedAt: '2026-09-10T12:40:00+02:00',
durationMinutes: 145,
localDate: '2026-09-10',
sourceStatus: 'suggested',
confidence: 82,
confidenceBand: 'high',
state: 'new',
target: null,
acceptedPlaceId: null,
acceptedJournalEntryId: null,
acceptedBucketListItemId: null,
sourceChanged: false,
sourceMissing: false,
matchedBucketListItemId: null,
matchedBucketListName: null,
countryCode: 'DE',
firstSeenAt: '2026-09-10T06:00:00Z',
lastSeenAt: '2026-09-10T06:00:00Z',
}
const suggestionList: DawarichSuggestionList = {
suggestions: [suggestion],
connected: true,
lastSyncAt: '2026-09-13T21:00:00Z',
lastSyncState: 'ok',
lastSyncError: null,
}
const track: DawarichTrack = {
days: [{ date: '2026-09-10', segments: [] }],
source: 'tracks',
fetchedAt: '2026-09-13T21:05:00Z',
pointCount: 0,
truncated: false,
}
const bucketScan: DawarichBucketScan = {
matches: [{ itemId: 3, name: 'Uluru', match: null, alreadyVisited: false }],
skippedWithoutCoordinates: 1,
truncated: false,
fetchedAt: '2026-09-13T21:05:00Z',
}
const atlas: DawarichAtlasSuggestions = {
countries: [{ countryCode: 'DE', sourceName: 'Germany', cities: [], alreadyVisited: false }],
unresolved: ['Kosovo'],
fetchedAt: '2026-09-13T21:05:00Z',
}
beforeEach(() => {
requestUrl = ''
requestBody = undefined
// Berlin in summer. localOffsetMinutes() negates getTimezoneOffset(), so the
// value on the wire is +120 (positive for a zone ahead of UTC), which is the
// sign convention the server's day resolution expects.
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('dawarichApi connection', () => {
it('FE-DAWARICH-API-001: getSettings() reads the connection card and unwraps the body', async () => {
server.use(http.get(`${BASE}/settings`, record(connection)))
const res = await dawarichApi.getSettings()
expect(requestUrl).toContain(`${BASE}/settings`)
expect(res).toEqual(connection)
})
// The three warning fields are why this endpoint does not just answer
// `{ success }`: the server writes its sentence in English, the client renders
// the code in the reader's language and quotes the resolved address back.
// Dropping any of them leaves a private-IP connection looking perfectly healthy.
it('FE-DAWARICH-API-002: saveSettings() puts the settings and keeps the warning triple', async () => {
server.use(
http.put(
`${BASE}/settings`,
record({
success: true,
warning: 'That host resolves to a private address.',
warningCode: 'private_ip',
warningIp: '192.168.178.72',
}),
),
)
const res = await dawarichApi.saveSettings({
url: 'https://dawarich.lan',
apiKey: 'secret',
allowInsecureTls: true,
syncEnabled: false,
})
expect(requestBody).toEqual({
url: 'https://dawarich.lan',
apiKey: 'secret',
allowInsecureTls: true,
syncEnabled: false,
})
expect(res.warningCode).toBe('private_ip')
expect(res.warningIp).toBe('192.168.178.72')
expect(res.warning).toContain('private address')
})
it('FE-DAWARICH-API-003: disconnect() deletes the stored connection', async () => {
server.use(http.delete(`${BASE}/settings`, record({ success: true })))
expect(await dawarichApi.disconnect()).toEqual({ success: true })
expect(requestUrl).toContain(`${BASE}/settings`)
})
// A probe runs before anything is stored, so the body is deliberately partial:
// a url with no key is the shape the connect dialog sends on its first attempt.
it('FE-DAWARICH-API-004: test() posts a partial settings body and returns the probe verdict', async () => {
server.use(http.post(`${BASE}/test`, record({ connected: false, error: 'unauthorized' })))
const res = await dawarichApi.test({ url: 'https://dawarich.example' })
expect(requestBody).toEqual({ url: 'https://dawarich.example' })
expect(res.connected).toBe(false)
expect(res.error).toBe('unauthorized')
})
// `alreadyRunning` is how a second press of Sync Now is told apart from a sync
// that genuinely found nothing; both report created/updated/missing as zero.
it('FE-DAWARICH-API-005: syncNow() posts to the sync endpoint and passes alreadyRunning through', async () => {
server.use(http.post(`${BASE}/sync`, record({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true })))
const res = await dawarichApi.syncNow()
expect(requestUrl).toContain(`${BASE}/sync`)
expect(res.alreadyRunning).toBe(true)
})
})
describe('dawarichApi suggestions', () => {
it('FE-DAWARICH-API-006: listSuggestions() sends the filter as query params, and none when unfiltered', async () => {
server.use(http.get(`${BASE}/suggestions`, record(suggestionList)))
const res = await dawarichApi.listSuggestions({ tripId: 7, state: 'new' })
expect(query().get('tripId')).toBe('7')
expect(query().get('state')).toBe('new')
expect(res.suggestions).toHaveLength(1)
await dawarichApi.listSuggestions()
expect(new URL(requestUrl).search).toBe('')
})
it('FE-DAWARICH-API-007: accept() posts the corrected stay to the suggestion it belongs to', async () => {
server.use(
http.post(
`${BASE}/suggestions/:id/accept`,
record({ suggestion, createdPlaceId: 900, createdJournalEntryId: null, bucketListItemId: null }),
),
)
const res = await dawarichApi.accept(41, { target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
expect(requestUrl).toContain(`${BASE}/suggestions/41/accept`)
expect(requestBody).toEqual({ target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
expect(res.createdPlaceId).toBe(900)
})
it('FE-DAWARICH-API-008: setState() puts the new state on the suggestion', async () => {
server.use(http.put(`${BASE}/suggestions/:id/state`, record({ ...suggestion, state: 'dismissed' })))
const res = await dawarichApi.setState(41, 'dismissed')
expect(requestUrl).toContain(`${BASE}/suggestions/41/state`)
expect(requestBody).toEqual({ state: 'dismissed' })
expect(res.state).toBe('dismissed')
})
})
describe('dawarichApi bucket list', () => {
it('FE-DAWARICH-API-009: scanBucketList() posts to the scan endpoint', async () => {
server.use(http.post(`${BASE}/bucket-list/scan`, record(bucketScan)))
const res = await dawarichApi.scanBucketList()
expect(requestUrl).toContain(`${BASE}/bucket-list/scan`)
expect(res.skippedWithoutCoordinates).toBe(1)
})
// `visitedAt` is optional because the default is "whenever the recording says",
// which only the server knows. An omitted one must not turn into a null on the
// wire, which the server would read as an instruction to store no date at all.
it('FE-DAWARICH-API-010: confirmBucketVisits() sends visitedAt only when the caller supplied one', async () => {
server.use(http.post(`${BASE}/bucket-list/confirm`, record({ updated: 2 })))
const res = await dawarichApi.confirmBucketVisits([3, 4], '2026-09-10T10:15:00Z')
expect(requestBody).toEqual({ itemIds: [3, 4], visitedAt: '2026-09-10T10:15:00Z' })
expect(res.updated).toBe(2)
await dawarichApi.confirmBucketVisits([3])
expect(requestBody).toEqual({ itemIds: [3] })
})
it('FE-DAWARICH-API-011: clearBucketVisit() deletes the visit off one entry', async () => {
server.use(http.delete(`${BASE}/bucket-list/:id/visit`, record({ success: true })))
expect(await dawarichApi.clearBucketVisit(3)).toEqual({ success: true })
expect(requestUrl).toContain(`${BASE}/bucket-list/3/visit`)
})
})
describe('dawarichApi atlas', () => {
it('FE-DAWARICH-API-012: atlasSuggestions() sends the window as from/to', async () => {
server.use(http.get(`${BASE}/atlas/suggestions`, record(atlas)))
const res = await dawarichApi.atlasSuggestions('2026-01-01', '2026-12-31')
expect(query().get('from')).toBe('2026-01-01')
expect(query().get('to')).toBe('2026-12-31')
expect(res.unresolved).toEqual(['Kosovo'])
})
it('FE-DAWARICH-API-013: acceptAtlasCountries() posts the codes the user ticked', async () => {
server.use(http.post(`${BASE}/atlas/accept`, record({ marked: 2 })))
const res = await dawarichApi.acceptAtlasCountries(['DE', 'NL'])
expect(requestBody).toEqual({ countryCodes: ['DE', 'NL'] })
expect(res.marked).toBe(2)
})
})
describe('dawarichApi track overlay', () => {
it('FE-DAWARICH-API-014: tripTrack() sends the range plus the reader\'s own UTC offset', async () => {
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
const res = await dawarichApi.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' })
expect(new URL(requestUrl).pathname).toBe(`${BASE}/trips/7/track`)
expect(query().get('from')).toBe('2026-09-10')
expect(query().get('to')).toBe('2026-09-12')
expect(query().get('offset')).toBe('120')
expect(res.source).toBe('tracks')
})
// The whole-trip case passes no range at all. The offset still has to be there:
// without it the server has to guess a day boundary for every recorded instant.
it('FE-DAWARICH-API-015: tripTrack() without a range still sends the offset and nothing else', async () => {
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
await dawarichApi.tripTrack(7)
expect([...query().keys()]).toEqual(['offset'])
expect(query().get('offset')).toBe('120')
})
it('FE-DAWARICH-API-016: windowTrack() sends from, to and the offset to the window endpoint', async () => {
server.use(http.get(`${BASE}/track`, record(track)))
const res = await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
expect(new URL(requestUrl).pathname).toBe(`${BASE}/track`)
expect(query().get('from')).toBe('2026-09-10')
expect(query().get('to')).toBe('2026-09-12')
expect(query().get('offset')).toBe('120')
expect(res.pointCount).toBe(0)
})
// The health handler is here because the axios error interceptor probes
// reachability whenever an error carries no response, and a cancellation is
// exactly that. Answering the probe keeps the detour short; whichever verdict
// it comes back with, the cancellation has to be re-rejected untouched, and
// that is what this pins.
it('FE-DAWARICH-API-017: an aborted windowTrack rejects as a cancellation, not as a result', async () => {
server.use(
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
http.get(`${BASE}/track`, async () => {
await delay(50)
return HttpResponse.json(track)
}),
)
const ctrl = new AbortController()
const pending = dawarichApi.windowTrack('2026-09-10', '2026-09-12', ctrl.signal)
ctrl.abort()
await expect(pending).rejects.toMatchObject({ code: 'ERR_CANCELED' })
})
// Nothing in this file catches, on purpose: the connection card and the
// suggestions hook both read the upstream reason off `response.data.code` to
// say it in the reader's language. A swallowed rejection would reach them as a
// successful call carrying an undefined body.
it('FE-DAWARICH-API-018: a 502 rejects with the upstream reason code intact', async () => {
server.use(
http.get(`${BASE}/suggestions`, () =>
HttpResponse.json({ error: 'Dawarich is unreachable', code: 'unreachable' }, { status: 502 }),
),
)
await expect(dawarichApi.listSuggestions()).rejects.toMatchObject({
response: { status: 502, data: { code: 'unreachable' } },
})
})
// Every other test here runs in Berlin, where -120 negated and -120 made
// positive look identical. New York separates them: the platform reports +300
// and the wire has to carry -300. A `Math.abs` slipped in would leave every
// European install working perfectly and put every American one a day out,
// which is the kind of bug that gets reported as "sometimes wrong".
//
// UTC is the second half of the pair: the offset there is falsy, and a guard
// that only sends it "when there is one" would drop it exactly for the readers
// whose day boundary the server would then have to guess.
it('FE-DAWARICH-API-019: the offset is the negation of getTimezoneOffset, not its magnitude', async () => {
server.use(http.get(`${BASE}/track`, record(track)))
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300)
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
expect(query().get('offset')).toBe('-300')
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0)
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
expect(query().get('offset')).toBe('0')
})
})
describe('dawarichApi: how long the browser waits', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => {
// The server gives each request to Dawarich 15 s on its own. At the shared
// 8 s the browser gave up first, the server cached the answer anyway, and the
// recorded route turned up only after a reload.
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} })
await dawarichApi.tripTrack(7)
await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')
await dawarichApi.test({ url: 'https://dawarich.example' })
await dawarichApi.syncNow()
await dawarichApi.scanBucketList()
await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true })
const configs = [
...get.mock.calls.map(call => call[1]),
...post.mock.calls.map(call => call[2]),
...put.mock.calls.map(call => call[2]),
]
expect(configs).toHaveLength(6)
for (const config of configs) {
expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }))
}
expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000)
// The track calls still carry their offset alongside the longer wait.
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) }))
})
it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z')
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS }))
expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS)
// Reading suggestions and confirming countries touch TREK's own database
// only; a two-minute wait there would just hide a stuck server.
await dawarichApi.listSuggestions()
await dawarichApi.acceptAtlasCountries(['DE'])
expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() }))
expect(post.mock.calls[0][2]).toBeUndefined()
})
})
+107
View File
@@ -0,0 +1,107 @@
import apiClient from './client'
import type {
DawarichAccept,
DawarichAcceptResult,
DawarichAtlasSuggestions,
DawarichBucketScan,
DawarichConnection,
DawarichSettings,
DawarichStatus,
DawarichSuggestion,
DawarichSuggestionList,
DawarichTrack,
} from '@trek/shared'
/**
* The Dawarich surface, in its own file rather than in the `client.ts` god
* module. Types come from the shared contracts, so a server change that the
* client has not caught up with is a typecheck failure rather than a runtime
* surprise.
*/
/**
* How long the browser waits for a call that makes the server go and ask
* Dawarich.
*
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
* these: the server allows each request to Dawarich 15 s by itself, walks pages
* of points for a route, and runs six probes one after another to test a
* connection. Cut off at 8 s, the browser gave up while the server carried on
* and cached the answer, so the recorded route showed "unavailable" and then
* appeared on the next reload.
*/
export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000
/**
* The Atlas asks for a year of visited cities a month at a time, one request
* after another, so it gets longer again. Still bounded, and under Node's own
* five-minute request limit on the server side.
*/
export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }
export const dawarichApi = {
// ── Connection ─────────────────────────────────────────────────────────────
getSettings: (): Promise<DawarichConnection> =>
apiClient.get('/integrations/dawarich/settings').then(r => r.data),
saveSettings: (data: DawarichSettings): Promise<{
success: boolean
/** The server's own sentence, English — the fallback when the code below is unknown here. */
warning?: string
/** What the warning is about, so the UI can say it in the reader's language. */
warningCode?: string
/** The address it resolved to, for the private-IP warning. */
warningIp?: string
}> =>
apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data),
disconnect: (): Promise<{ success: boolean }> =>
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data),
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
// ── Suggestions ────────────────────────────────────────────────────────────
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
accept: (id: number, body: DawarichAccept): Promise<DawarichAcceptResult> =>
apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
setState: (id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion> =>
apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
// ── Bucket list ────────────────────────────────────────────────────────────
scanBucketList: (): Promise<DawarichBucketScan> =>
apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data),
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
// ── Atlas ──────────────────────────────────────────────────────────────────
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
apiClient
.get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS })
.then(r => r.data),
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
// ── Track overlay ──────────────────────────────────────────────────────────
//
// Recorded points are bare instants, so the server cannot know which day they
// belong to without being told whose day is meant. The browser is the only
// party that knows, so it says so — otherwise an evening east of UTC lands on
// the wrong day of the trip.
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
}
/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
function localOffsetMinutes(): number {
return -new Date().getTimezoneOffset()
}
+13
View File
@@ -0,0 +1,13 @@
import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared'
import apiClient from './client'
const base = '/school-holiday-catalog'
export const schoolHolidayCatalogApi = {
catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)),
region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country),
deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`),
createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }),
}
+3
View File
@@ -35,6 +35,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// second copy of the same list to keep in step.
'roadtripVia:changed',
'roadtripTrack:changed',
'roadtripPreferences:changed',
// components/Roadtrip/useDayBoundaries reloads the shared boundaries.
'roadtripBoundary:changed',
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
@@ -473,7 +473,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
@@ -483,9 +483,9 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
@@ -545,7 +545,7 @@ describe('AddonManager', () => {
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
@@ -565,8 +565,8 @@ describe('AddonManager', () => {
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
+19 -3
View File
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
@@ -15,6 +17,9 @@ import AddonSubRow from './AddonSubRow'
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -59,9 +64,20 @@ interface ProviderOption {
interface AddonIconProps {
name: string
size?: number
/** A switched-off addon greys its icon out, brand marks included. */
enabled?: boolean
}
function AddonIcon({ name, size = 20 }: AddonIconProps) {
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
// The brand marks are badges, not glyphs: they fill the framed slot instead of
// sitting in the middle of it at glyph size, and they grey out when the addon
// is off — the lucide glyphs get that from the slot's text colour, an <img>
// has to be told.
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
// means every existing install still carries it — so the brand is keyed on
// that rather than on a new name no row would ever have.
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
@@ -212,7 +228,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return (
<AddonTile
key={addon.id}
icon={<AddonIcon name={addon.icon} size={18} />}
icon={<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />}
name={label.name}
description={label.description}
enabled={addon.enabled}
@@ -308,7 +324,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
{ id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
]
/**
+3 -1
View File
@@ -44,7 +44,9 @@ export default function AddonTile({
>
<div className="flex items-start gap-3 p-4">
<div
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
// overflow-hidden so a brand mark that fills the slot keeps the slot's
// rounded corners instead of squaring them off.
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
}`}
>
@@ -192,7 +192,7 @@ export default function CategoryManager() {
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
</div>
<button type="button" onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
@@ -19,6 +19,7 @@ import {
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -34,7 +35,7 @@ const MAP_PRESETS = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
type Defaults = {
type Defaults = RoutingDefaults & {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
@@ -43,8 +44,6 @@ type Defaults = {
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
routing_base_url?: string
valhalla_base_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -121,8 +120,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [valhallaBase, setValhallaBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -132,8 +129,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setRoutingBase(data.routing_base_url || '')
setValhallaBase(data.valhalla_base_url || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -156,8 +151,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'routing_base_url') setRoutingBase('')
if (key === 'valhalla_base_url') setValhallaBase('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -387,53 +380,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
{/* Instance configuration, not a taste, which is why it sits here rather
than in a user's own Map settings. The origin has to appear in the CSP
`connect-src` the server emits at boot, and that list is built from
this default — a value on a personal settings row is read by the route
calculator and then refused by the browser, so the whole app stops
routing with no error it could report. The write route refuses one
from a non-admin for the same reason. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.routingBase')}
<ResetButton field="routing_base_url" />
</label>
<input
type="text"
value={routingBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
onBlur={() => save({ routing_base_url: routingBase.trim() })}
placeholder="https://osrm.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
</div>
)}
{/* The second engine, next to the first because it is the same kind of decision
and carries the same restart caveat. Only ever asked to leave a road class
out of one leg, which is the one question the OSRM above refuses. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.valhallaBase')}
<ResetButton field="valhalla_base_url" />
</label>
<input
type="text"
value={valhallaBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
placeholder="https://valhalla.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
</div>
<div className="mt-3.5">
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
@@ -156,7 +156,7 @@ export default function PackingTemplateManager() {
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button type="button" onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
@@ -0,0 +1,64 @@
import { useId, useState } from 'react'
import { useTranslation } from '../../i18n/TranslationContext'
export interface RoutingDefaults {
routing_base_url?: string
valhalla_base_url?: string
}
interface RoutingFieldsProps {
defaults: RoutingDefaults
onSave: (patch: RoutingDefaults) => Promise<void>
onReset: (key: keyof RoutingDefaults) => Promise<void>
hintClassName?: string
}
const ROUTERS = [
{ key: 'routing_base_url', label: 'settings.routingBase', hint: 'settings.routingBaseHint', placeholder: 'https://osrm.example.org' },
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
] as const
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
const { t } = useTranslation()
const id = useId()
const [draft, setDraft] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const saved = defaults[router.key] ?? ''
const commit = async () => {
if (draft === null) return
const value = draft.trim()
if (value === saved) { setDraft(null); return }
setSaving(true)
try { await onSave({ [router.key]: value }) }
finally { setDraft(null); setSaving(false) }
}
const reset = async () => {
setSaving(true)
try { await onReset(router.key) }
finally { setDraft(null); setSaving(false) }
}
return (
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
{defaults[router.key] !== undefined && (
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
)}
</div>
<input id={id} type="url" inputMode="url" value={draft ?? saved} disabled={saving}
onChange={e => setDraft(e.target.value)} onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
aria-describedby={`${id}-hint`}
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
/>
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
</div>
)
}
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
}
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { within } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores } from '../../../tests/helpers/store'
import SchoolHolidayCatalog from './SchoolHolidayCatalog'
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
import { fetchSchoolHolidayRegionOptions } from '../Vacay/holidayRegions'
import { useSchoolHolidayCountries } from '../Vacay/useSchoolHolidayCountries'
import { useVacayStore } from '../../store/vacayStore'
import type { SchoolHolidayRegionDetail } from '@trek/shared'
const region: SchoolHolidayRegionDetail = { id: 7, code: 'US-MANUAL-7', country: 'US', name: 'Seattle schools', revision: 1, holidays: [{ name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }] }
const catalog = { countries: [{ code: 'US', name: 'USA' }], regions: [region] }
beforeEach(() => resetAllStores())
function enterDate(label: string, value: string) {
const picker = within(screen.getByRole('group', { name: label }))
fireEvent.click(picker.getByRole('button', { name: /enter.*manually/i }))
const input = picker.getByRole('textbox')
fireEvent.change(input, { target: { value } })
fireEvent.blur(input)
}
describe('manual school holiday administration', () => {
it('creates a country, a district and its named periods', async () => {
let created = false
const saves: unknown[] = []
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: created ? catalog.countries : [], regions: [] })),
http.post('/api/school-holiday-catalog/countries', async ({ request }) => { expect(await request.json()).toEqual({ code: 'US', name: 'USA' }); created = true; return HttpResponse.json(catalog.countries[0]) }),
http.post('/api/school-holiday-catalog/countries/US/regions', async ({ request }) => { saves.push(await request.json()); return HttpResponse.json(region) }),
)
render(<SchoolHolidayCatalog />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Add country' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add country' }))
fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'USA' } })
fireEvent.change(screen.getByLabelText('Country code (e.g. US)'), { target: { value: 'us' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Add region' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add region' }))
fireEvent.change(screen.getByLabelText('Region or school district'), { target: { value: 'Seattle schools' } })
fireEvent.click(screen.getByRole('button', { name: 'Add holiday period' }))
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'Winter break' } })
enterDate('Start date', '2026-12-20')
enterDate('End date', '2027-01-06')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saves).toEqual([{ name: region.name, revision: 0, holidays: region.holidays }]))
await waitFor(() => expect(screen.queryByLabelText('Holiday name')).not.toBeInTheDocument())
})
it('opens existing periods and keeps edits after a failed save', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
http.get('/api/school-holiday-catalog/regions/7', () => HttpResponse.json(region)),
http.put('/api/school-holiday-catalog/regions/7', () => HttpResponse.json({ error: 'This region changed. Reopen it before saving again.' }, { status: 409 })),
)
render(<SchoolHolidayCatalog />)
fireEvent.click(await screen.findByRole('button', { name: 'Seattle schools' }))
await screen.findByDisplayValue('Winter break')
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'New name' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toHaveTextContent('This region changed')
expect(screen.getByLabelText('Holiday name')).toHaveValue('New name')
})
it('validates names and confirms discarding changes', async () => {
const save = vi.fn()
const close = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={close} onSave={save} />)
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: ' ' } })
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
expect(save).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('Check the names and dates')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByText('Discard unsaved changes?')).toBeInTheDocument()
expect(close).not.toHaveBeenCalled()
})
it('removes a period and saves an existing region without changing its revision token', () => {
const save = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={vi.fn()} onSave={save} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete Winter break' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(save).toHaveBeenCalledWith({ name: region.name, revision: 1, holidays: [] })
})
it('blocks offline edits but still lets the dialog close', () => {
const close = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} offline error="Offline" onClose={close} onSave={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(close).toHaveBeenCalledOnce()
})
it('keeps deletion conflicts visible and allows retrying the catalog', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
http.delete('/api/school-holiday-catalog/regions/7', ({ request }) => {
expect(new URL(request.url).searchParams.get('revision')).toBe('1')
return HttpResponse.json({ error: 'Region is in use' }, { status: 409 })
}),
)
render(<SchoolHolidayCatalog />)
fireEvent.click(await screen.findByRole('button', { name: 'Delete Seattle schools' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Region is in use')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'Seattle schools' })).toBeInTheDocument()
})
it('keeps national API calendars selectable alongside manual regions', async () => {
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'IE', name: 'Ireland' }], regions: [{ ...region, country: 'IE', code: 'IE-MANUAL-7' }] })))
expect(await fetchSchoolHolidayRegionOptions('IE')).toEqual([{ value: 'IE', label: 'IE' }, { value: 'IE-MANUAL-7', label: region.name }])
})
it('keeps API regions available if loading the manual catalog fails', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({}, { status: 502 })),
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({ subdivisions: [{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }] })),
)
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-BY', label: 'Bavaria' }])
await expect(fetchSchoolHolidayRegionOptions('US')).rejects.toThrow()
})
it('offers USA independently of provider countries and fetches only local regions', async () => {
const provider = vi.fn(() => HttpResponse.json([]))
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), http.get('/api/addons/vacay/school-holidays/regions/US', provider))
function Countries() { const { countries } = useSchoolHolidayCountries(); return <>{countries.map(country => <span key={country.value}>{country.label}</span>)}</> }
render(<Countries />)
expect(await screen.findByText('USA')).toBeInTheDocument()
expect(await fetchSchoolHolidayRegionOptions('US')).toEqual([{ value: 'US-MANUAL-7', label: 'Seattle schools' }])
expect(provider).not.toHaveBeenCalled()
})
it('keeps manual regions usable when a supported country provider fails', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'DE', name: 'Germany' }], regions: [{ ...region, country: 'DE', code: 'DE-MANUAL-7' }] })),
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({}, { status: 502 })),
)
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-MANUAL-7', label: region.name }])
})
it('renders manual dates with inclusive boundaries without touching leave entries', async () => {
server.use(http.get('/api/school-holiday-catalog/regions/7/holidays/2026', () => HttpResponse.json(region.holidays)))
useVacayStore.setState({
selectedYear: 2026,
plan: { id: 1, school_holidays_enabled: true, holidays_enabled: false, holidays_region: null, block_weekends: true, company_holidays_enabled: true, carry_over_enabled: false, holiday_calendars: [{ id: 1, plan_id: 1, region: region.code, label: 'School', color: '#a5f3fc', sort_order: 0, type: 'school_holiday' }] },
entries: [{ plan_id: 1, user_id: 1, date: '2026-12-23', fraction: 1, kind: 'vacation' }],
})
const entries = useVacayStore.getState().entries
await useVacayStore.getState().loadHolidays(2026)
expect(useVacayStore.getState().holidays['2026-12-20']).toBeDefined()
expect(useVacayStore.getState().holidays['2026-12-31']).toBeDefined()
expect(useVacayStore.getState().entries).toBe(entries)
})
});
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
import ConfirmDialog from '../shared/ConfirmDialog'
import ErrorBoundary from '../shared/ErrorBoundary'
import CustomSelect from '../shared/CustomSelect'
export default function SchoolHolidayCatalog() {
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
}
function Catalog() {
const { t } = useTranslation()
const state = useSchoolHolidayCatalog()
const [country, setCountry] = useState('')
const [adding, setAdding] = useState(false)
const [code, setCode] = useState('')
const [name, setName] = useState('')
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
const disabled = state.busy || state.loading || state.offline
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
<div>
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
</div>
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
<fieldset disabled={disabled} className="space-y-4">
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
</div>
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
</div>}
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
event.preventDefault()
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
}}>
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
</form>}
{selectedCountry && <>
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
</li>)}</ul>
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
</>}
</fieldset>
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
</section>
}
@@ -0,0 +1,75 @@
import { useId, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
import { useTranslation } from '../../i18n'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
region: SchoolHolidayRegionDetail
busy: boolean
offline?: boolean
error: string
onClose: () => void
onSave: (body: SchoolHolidayRegionRequest) => void
}) {
const { t } = useTranslation()
const fieldId = useId()
const [name, setName] = useState(region.name)
const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index })))
const [nextKey, setNextKey] = useState(holidays.length)
const [discard, setDiscard] = useState(false)
const [invalid, setInvalid] = useState(false)
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
return <>
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
<form className="space-y-4" onSubmit={event => {
event.preventDefault()
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
setInvalid(!parsed.success)
if (parsed.success) onSave(parsed.data)
}}>
<fieldset disabled={busy || offline} className="space-y-4">
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
</label>
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
<div className="space-y-1 text-body">
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
<div className="flex items-stretch gap-2">
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
</div>
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
</div>
</div>
</div>)}
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
{error && <p role="alert" className="text-body text-danger">{error}</p>}
</fieldset>
<div className="flex justify-end gap-2 border-t border-edge pt-4">
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
</div>
</form>
</Modal>
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
</>
}
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared'
import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo'
import { useTranslation } from '../../i18n'
import { useNetworkMode } from '../../hooks/useNetworkMode'
export function useSchoolHolidayCatalog() {
const { t } = useTranslation()
const { offline } = useNetworkMode()
const [catalog, setCatalog] = useState<SchoolHolidayCatalog>({ countries: [], regions: [] })
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [editor, setEditor] = useState<SchoolHolidayRegionDetail | null>(null)
const mounted = useRef(true)
const pending = useRef(false)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
const reload = useCallback(async () => {
catalogRepo.requireOnline()
const catalog = await catalogRepo.catalog()
if (mounted.current) setCatalog(catalog)
}, [])
useEffect(() => {
let cancelled = false
if (offline) { setLoading(false); return }
setLoading(true)
catalogRepo.catalog().then(catalog => {
if (!cancelled) { setCatalog(catalog); setError('') }
}).catch(() => { if (!cancelled) setError(t('common.error')) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [offline, t])
async function run(action: () => Promise<unknown>, after?: () => void) {
if (pending.current) return false
pending.current = true
setBusy(true)
setError('')
try {
catalogRepo.requireOnline()
await action()
if (mounted.current) after?.()
try {
await reload()
} catch {
if (mounted.current) setError(t('schoolCatalog.refreshError'))
}
return true
} catch (failure) {
if (mounted.current) {
const message = failure instanceof Error ? failure.message : ''
const http = failure as { response?: { data?: { error?: string } } }
setError(http.response?.data?.error || message || t('common.error'))
}
return false
} finally {
pending.current = false
if (mounted.current) setBusy(false)
}
}
return {
catalog, loading, busy, error, offline, editor, setEditor,
refresh: () => run(async () => {}),
createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)),
deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)),
openRegion: (id: number) => run(async () => {
const region = await catalogRepo.region(id)
if (mounted.current) setEditor(region)
}),
saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run(
() => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body),
() => setEditor(null),
),
deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)),
}
}
@@ -0,0 +1,48 @@
import { useRef, type RefObject } from 'react'
export interface GlassGlare {
/** Goes on the panel itself — the glare is measured against its box. */
panelRef: RefObject<HTMLDivElement | null>
/** The soft light under the cursor. */
glareRef: RefObject<HTMLDivElement | null>
/** The lit stretch of border around it. */
borderGlareRef: RefObject<HTMLDivElement | null>
onMouseMove: (e: { clientX: number; clientY: number }) => void
onMouseLeave: () => void
}
/**
* The Atlas panels' liquid-glass hover: a pool of light under the cursor and the
* piece of border nearest to it lighting up.
*
* Extracted so a second panel beside the first one behaves identically. Two
* hand-copied versions of a gradient are two versions that drift, and the whole
* point of the effect is that the panels look like one material.
*
* Written straight to the DOM rather than through state: this runs on every
* mousemove, and a re-render per frame would cost more than the effect is worth.
*/
export function useGlassGlare(dark: boolean): GlassGlare {
const panelRef = useRef<HTMLDivElement>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const onMouseMove = (e: { clientX: number; clientY: number }): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const onMouseLeave = (): void => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave }
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
describe('splitCents', () => {
it('splits evenly when it divides cleanly', () => {
@@ -188,6 +188,21 @@ describe('readUserNote', () => {
})
})
describe('settlementDate', () => {
it('prefers settled_at over created_at', () => {
expect(settlementDate({ settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-05')
})
it('falls back to the day it was recorded when settled_at is unset', () => {
expect(settlementDate({ created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
expect(settlementDate({ settled_at: null, created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
})
it('is empty when neither is set', () => {
expect(settlementDate({})).toBe('')
})
})
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
describe('calculateTicketShares', () => {
@@ -231,3 +246,67 @@ describe('calculateTicketShares', () => {
expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 })
})
})
describe('paidByUser', () => {
it('adds up what one participant fronted and ignores the other payers', () => {
const item = { payers: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }, { user_id: 1, amount: 5 }] }
expect(paidByUser(item, 1)).toBe(35)
expect(paidByUser(item, 2)).toBe(70)
expect(paidByUser(item, 3)).toBe(0)
})
it('is zero for an expense with no payer list', () => {
expect(paidByUser({ payers: null }, 1)).toBe(0)
expect(paidByUser({}, 1)).toBe(0)
})
})
describe('finalBudgetFor', () => {
const finals = [{
user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50,
sources: { fronted: [{ item_id: 1, cents: 10000 }], moved: [], outstanding: [] },
}]
it("returns the server's row for a participant in the ledger", () => {
expect(finalBudgetFor(finals, { id: 1, username: 'alice' })).toBe(finals[0])
})
it('reads a participant the ledger left out as costing nothing, with nothing behind it', () => {
expect(finalBudgetFor(finals, { id: 2, username: 'bob' })).toEqual({
user_id: 2, username: 'bob', avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0,
sources: { fronted: [], moved: [], outstanding: [] },
})
})
})
describe('finalBudgetSources', () => {
const items = [{ id: 1, name: 'Dinner' }, { id: 2, name: 'Taxi' }]
const sources = {
fronted: [{ item_id: 1, cents: 6000 }, { item_id: 3, cents: -1000 }],
moved: [
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 1500 },
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, cents: -500 },
],
outstanding: [{ from_user_id: 3, to_user_id: 1, cents: 501 }],
}
it("names the expenses and prints the server's cents as amounts, a refund's negative row included", () => {
const { fronted } = finalBudgetSources({ sources }, items)
// An expense the list does not know yet keeps its row; only the name is missing.
expect(fronted).toEqual([{ item_id: 1, name: 'Dinner', amount: 60 }, { item_id: 3, name: '?', amount: -10 }])
})
it('keeps the transfers and open flows signed as the server sent them', () => {
const { moved, outstanding } = finalBudgetSources({ sources }, items)
expect(moved).toEqual([
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, amount: 15 },
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, amount: -5 },
])
expect(outstanding).toEqual([{ from_user_id: 3, to_user_id: 1, amount: 5.01 }])
})
it('is empty for a participant with no activity', () => {
expect(finalBudgetSources({ sources: { fronted: [], moved: [], outstanding: [] } }, items))
.toEqual({ fronted: [], moved: [], outstanding: [] })
})
})
@@ -11,6 +11,7 @@
* Amounts are the raw input strings, parsed on use (same as customAmounts).
*/
import type { BudgetParticipantFinal } from '@trek/shared'
import { currencyDecimals } from '../../utils/formatters'
// The split and receipt fields guard their own precision on every keystroke, so the
@@ -34,6 +35,67 @@ export function splitCents(amount: number, n: number): number[] {
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
/**
* The calendar day a settle-up payment counts on for grouping/filtering: its own
* `settled_at` if the user set one, else the day it was recorded (`created_at`).
* Mirrors `expense_date` falling back nowhere on a budget item — a settlement's
* `created_at` doubled as its date before `settled_at` existed, so this keeps
* every pre-existing row grouped exactly where it already was.
*/
export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string {
return (s.settled_at || s.created_at || '').slice(0, 10)
}
/**
* What one participant fronted for an expense, in the expense's own currency.
*
* Several payers can share one bill, and the same person can appear only once,
* but the reduce covers a row that somehow carries them twice rather than
* picking one of the two. The caller converts the result — this file never
* touches exchange rates.
*/
export function paidByUser(
item: { payers?: { user_id: number; amount: number }[] | null },
userId: number,
): number {
return (item.payers || []).filter(p => p.user_id === userId).reduce((a, p) => a + p.amount, 0)
}
/** The figures a final budget is made of, for someone the server left out of the ledger. */
export function finalBudgetFor(finals: BudgetParticipantFinal[], member: { id: number; username: string }): BudgetParticipantFinal {
// Absent means they neither fronted anything nor were split into an expense:
// the trip has cost them nothing, which is worth a row of its own.
return finals.find(f => f.user_id === member.id) || {
user_id: member.id, username: member.username, avatar_url: null,
expenses: 0, reimbursed: 0, pending: 0, final: 0,
sources: { fronted: [], moved: [], outstanding: [] },
}
}
/**
* The rows behind one traveler's final budget, ready to print. They arrive as
* ids and display cents: the server spreads each of the three figures over its
* rows with the same largest-remainder split the figure came from, so every list
* adds up to the line it sits under, in whatever currency was asked for and
* whether or not the live rates have loaded yet. Only the expense names are
* looked up here; who "you" is in a transfer is the shell's call.
*/
export function finalBudgetSources(
row: Pick<BudgetParticipantFinal, 'sources'>,
items: { id: number; name: string }[],
): {
fronted: { item_id: number; name: string; amount: number }[]
moved: { settlement_id: number; from_user_id: number; to_user_id: number; amount: number }[]
outstanding: { from_user_id: number; to_user_id: number; amount: number }[]
} {
const { fronted, moved, outstanding } = row.sources
return {
fronted: fronted.map(r => ({ item_id: r.item_id, name: items.find(i => i.id === r.item_id)?.name ?? '?', amount: r.cents / 100 })),
moved: moved.map(r => ({ settlement_id: r.settlement_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
outstanding: outstanding.map(r => ({ from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
}
}
/** Sum the amounts of the selected payers. */
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
@@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
import type { BudgetParticipantFinal } from '@trek/shared'
import type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal } from './CostsPanel'
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
@@ -20,6 +21,9 @@ const tripMembers = [
{ id: 2, username: 'bob', avatar_url: null },
]
/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */
const bobInForm = () => screen.getAllByRole('button', { name: /bob/i }).filter(b => !b.hasAttribute('aria-expanded'))
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
@@ -610,7 +614,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.type(itemNames[1], 'chocolate cake')
await user.type(itemPrices[2], '50')
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
const bobButtons = bobInForm()
await user.click(bobButtons[1])
await user.type(itemNames[2], 'Milk')
@@ -740,7 +744,7 @@ describe('CostsPanel — settlements in the ledger', () => {
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string }
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string; settled_at?: string | null }
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
@@ -749,7 +753,7 @@ const expense = (over: Partial<Omit<BudgetItem, 'members'>> & { members?: Member
function mount(
items: BudgetItem[],
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[] } = {},
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[]; finalBudgets?: BudgetParticipantFinal[] } = {},
entries?: string[],
) {
server.use(
@@ -826,8 +830,70 @@ describe('CostsPanel — overview', () => {
mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] })
// Both travellers appear; neither has a signed amount.
await screen.findByText('Balances')
expect(screen.getAllByText('0,00 €')).toHaveLength(2)
const balances = (await screen.findByText('Balances')).parentElement as HTMLElement
expect(within(balances).getAllByText('0,00 €')).toHaveLength(2)
})
it('FE-W5COSTS-004b: the final budget gives one amount per traveler and its arithmetic on click', async () => {
// Dinner is 100 USD fronted by Alice, booked by the server at the rate frozen
// on entry as 92 €; taxi 30 € by Bob; both split evenly, so the trip costs each
// of them 61. Bob has sent 15 of the 31 he owed, 16 is still open. No live rate
// is loaded here, so a client converting the dinner itself would print 100 €.
const user = userEvent.setup()
mount([{ ...dinner(), currency: 'USD', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }, taxi()], {
balances: [
{ user_id: 1, username: 'alice', avatar_url: null, balance: 16 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: -16 },
],
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 16 }],
settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 15, currency: 'EUR', created_at: '2025-06-17 10:00:00' }],
finalBudgets: [
{
user_id: 1, username: 'alice', avatar_url: null, expenses: 92, reimbursed: 15, pending: 16, final: 61,
sources: {
fronted: [{ item_id: 101, cents: 9200 }],
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: 1500 }],
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 1600 }],
},
},
{
user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -16, final: 61,
sources: {
fronted: [{ item_id: 102, cents: 3000 }],
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: -1500 }],
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -1600 }],
},
},
],
})
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2))
// The main view stays one figure per person until someone asks for more.
expect(within(card).queryByText('Expenses paid')).toBeNull()
const alice = within(card).getByRole('button', { name: /You/ })
await user.click(alice)
expect(alice).toHaveAttribute('aria-expanded', 'true')
// The dinner is the server's 92 €, once as the line and once as its only row;
// the raw 100 USD never shows up in the card.
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
expect(within(card).queryByText(/100,00/)).toBeNull()
// Received and still pending both lower her cost, each line with its one row.
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
expect(within(card).getByText('Dinner')).toBeInTheDocument()
expect(within(card).queryByText('Taxi')).toBeNull()
expect(within(card).getAllByText(/^bob → /)).toHaveLength(2)
// Opening Bob closes Alice: what he sent back and what he still owes raise his.
await user.click(within(card).getByRole('button', { name: /bob/ }))
expect(alice).toHaveAttribute('aria-expanded', 'false')
expect(within(card).getAllByText('+30,00 €')).toHaveLength(2)
expect(within(card).getAllByText('+15,00 €')).toHaveLength(2)
expect(within(card).getAllByText('+16,00 €')).toHaveLength(2)
expect(within(card).getByText('Taxi')).toBeInTheDocument()
expect(within(card).queryByText('Dinner')).toBeNull()
})
it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => {
@@ -980,8 +1046,8 @@ describe('CostsPanel — settle up', () => {
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Balances')
// Settle up and Balances both hang off the one failed request.
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2))
// Settle up, Balances and Final budget all hang off the one failed request.
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(3))
expect(screen.queryByText("Everyone's square")).toBeNull()
})
@@ -1051,6 +1117,21 @@ describe('CostsPanel — filtering the ledger', () => {
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
})
it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => {
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
// must follow settled_at, the same way it already follows expense_date over
// an expense's own created_at.
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
await screen.findByText('Taxi')
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
pickOption('Sun, Jun 15')
expect(screen.getByText('Dinner')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
expect(screen.getByText('Payment')).toBeInTheDocument()
})
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })])
@@ -1252,6 +1333,62 @@ describe('CostsPanel — payment modal', () => {
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
delete window.__addToast
})
it('FE-W5COSTS-074: a new payment defaults its day to today, by the local calendar', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
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: 9 } })
}))
mount([])
try {
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '10')
const submits = screen.getAllByRole('button', { name: 'Add payment' })
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.settled_at).toBe('2026-08-12')
} finally {
vi.useRealTimers()
}
})
it('FE-W5COSTS-075: editing a payment keeps its own settled day, not the day it was recorded', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 7 } })
}))
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
await user.click(await screen.findByTitle('Edit'))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.settled_at).toBe('2025-06-10')
})
it('FE-W5COSTS-077: a payment cannot be saved without a day', async () => {
// Cleared, the server would store NULL and the ledger would quietly move
// the payment back to the day it was recorded on.
const user = userEvent.setup()
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
await user.click(await screen.findByTitle('Edit'))
const save = screen.getByRole('button', { name: 'Save' })
expect(save).toBeEnabled()
await user.click(document.querySelector('button[aria-haspopup="dialog"]') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'Clear date' }))
expect(save).toBeDisabled()
})
})
describe('CostsPanel — expense modal', () => {
@@ -1289,7 +1426,7 @@ describe('CostsPanel — expense modal', () => {
const rows = screen.getAllByPlaceholderText('Item name')
await user.click(rows[1].parentElement!.parentElement!.querySelectorAll('button')[0])
expect(screen.queryByDisplayValue('Cake')).not.toBeInTheDocument()
await user.click(screen.getAllByRole('button', { name: /bob/i })[0])
await user.click(bobInForm()[0])
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
@@ -1330,7 +1467,8 @@ describe('CostsPanel — expense modal', () => {
expect(screen.getByDisplayValue('30,00')).toBeInTheDocument()
// Excluding Bob drops his amount; the split no longer matches the total.
await user.click(screen.getByRole('button', { name: /bob/i }))
expect(bobInForm()).toHaveLength(1)
await user.click(bobInForm()[0])
expect(screen.getByText(/Sum of splits/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
+140 -6
View File
@@ -18,9 +18,10 @@ import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { localToday } from '../Planner/today'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, 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 { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetParticipantFinal } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
@@ -41,6 +42,10 @@ interface Settlement {
// read as the display currency, which is what the server assumes for them too.
currency?: string | null
created_at?: string
// The day the transfer actually happened; editable, unlike created_at (when it
// was recorded). Null/absent on rows predating this field — settlementDate()
// falls back to created_at for those.
settled_at?: string | null
from_username?: string
to_username?: string
}
@@ -48,6 +53,9 @@ interface SettlementData {
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
settlements: Settlement[]
// What the trip ends up costing each participant. Computed server-side off the
// same ledger as the balances, so the breakdown can't contradict them.
finalBudgets: BudgetParticipantFinal[]
}
// One row in the unified Costs ledger — either an expense or a settle-up payment,
@@ -90,6 +98,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// One note open at a time: two expanded rows next to each other read as a mess,
// and the point of the collapse is that the list stays scannable.
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
// One open final-budget breakdown at a time, for the same reason a single note
// is expanded at a time: the card is a sidebar, not a report.
const [expandedFinalId, setExpandedFinalId] = useState<number | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
const [addingPayment, setAddingPayment] = useState(false)
@@ -125,7 +136,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// ── derived expense maths (everything converted to the base currency) ────
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
const myPaidOf = (e: BudgetItem) => convert(paidByUser(e, me), curOf(e))
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in. A negative
// total (a refund, #2176) is just as unfinished until its recipient is named.
@@ -178,14 +189,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (filter === 'owed') return []
let list = settlement?.settlements || []
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
if (dayFilter) list = list.filter(s => settlementDate(s) === dayFilter)
return list
}, [settlement, filter, search, catFilter, dayFilter, me])
const dayGroups = useMemo(() => {
const entries: LedgerEntry[] = [
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: settlementDate(s), s })),
]
const labelOf = (date: string) => {
if (!date) return t('costs.noDate')
@@ -474,6 +485,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* final budget */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
{FinalBudgetList()}
</div>
{/* by category */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
@@ -694,6 +711,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* Final budget */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
{FinalBudgetList()}
</div>
{/* By category */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
@@ -918,6 +941,112 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)
}
/**
* What the trip actually costs each traveler — one amount per person, with the
* arithmetic behind it a click away.
*
* The four figures come from the settlement response, not from a second pass
* over the expenses here: the server nets them in the same integer cents as
* the balances, so `expenses − reimbursed − pending` always lands on the total
* printed beside the name. The lists under the breakdown are the rows those
* figures came from, which is what makes an unexpected total explainable.
*/
function FinalBudgetList() {
if (settlementError) return loadFailed()
const finals = settlement?.finalBudgets || []
const rows = people.map(p => finalBudgetFor(finals, p))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{rows.map(r => {
const open = expandedFinalId === r.user_id
return (
<div key={r.user_id}>
<button type="button" onClick={() => setExpandedFinalId(open ? null : r.user_id)} aria-expanded={open}
className="text-content"
style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto 14px', gap: 10, alignItems: 'center', width: '100%', padding: '7px 0', background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left' }}>
<Avatar id={r.user_id} size={28} />
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{personName(r.user_id)}</span>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(r.final)}</span>
<ChevronDown size={14} className="text-content-faint" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{open && FinalBudgetBreakdown({ row: r })}
</div>
)
})}
</div>
)
}
/** The three lines behind one traveler's final budget, and the rows they add up from. */
function FinalBudgetBreakdown({ row }: { row: BudgetParticipantFinal }) {
// Each line is signed by what it does to the final, so the column reads as the
// subtraction it is: a reimbursement this traveler *sent* raises their cost and
// shows as a plus, which "received: −50" could never say. The rows under a line
// carry the same sign, so they visibly add up to it. Their amounts are the
// server's display cents, not a conversion of the expense list done here.
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
const detail = (label: string, value: string) => (
<div style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1 }}>{label}</span>
<span className="text-content" style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{value}</span>
</div>
)
const transferLabel = (fromId: number, toId: number) => `${personName(fromId)} → ${toId === me ? t('costs.youLower') : personName(toId)}`
// Capped and scrollable: a long trip's expense list would otherwise push the
// rest of the sidebar off the screen every time a name is tapped.
const listCls = { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 6, maxHeight: 180, overflowY: 'auto' } as const
return (
<div className="bg-surface-secondary" style={{ borderRadius: 12, padding: '11px 12px', margin: '2px 0 8px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{detail(t('costs.finalExpenses'), signed(row.expenses))}
{detail(t('costs.finalReimbursed'), signed(-row.reimbursed))}
{detail(t('costs.finalPending'), signed(-row.pending))}
</div>
{fronted.length > 0 && (
<div>
<div className={labelCls}>{t('costs.finalExpenses')}</div>
<div style={listCls}>
{fronted.map(r => (
<div key={r.item_id} style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
</div>
))}
</div>
</div>
)}
{moved.length > 0 && (
<div>
<div className={labelCls}>{t('costs.finalReimbursed')}</div>
<div style={listCls}>
{moved.map(r => (
<div key={r.settlement_id} style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
</div>
))}
</div>
</div>
)}
{outstanding.length > 0 && (
<div>
<div className={labelCls}>{t('costs.finalPending')}</div>
<div style={listCls}>
{outstanding.map((r, i) => (
<div key={i} style={lineCls}>
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
function CategoryBreakdown() {
// Categories net refunds against spend (#2176): a negative entry lowers its
// category's sum, and a category that nets negative keeps its own row —
@@ -1014,16 +1143,17 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
// "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals.
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
const [day, setDay] = useState(editing ? settlementDate(editing) : localToday())
const [saving, setSaving] = useState(false)
const amt = Number.parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const valid = amt > 0 && fromId !== toId && !!day
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur }
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
@@ -1067,6 +1197,10 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
style={{ width: '100%' }} />
</div>
</div>
<div>
<label className={labelCls}>{t('costs.day')}</label>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
</div>
</Modal>
)
@@ -0,0 +1,129 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabLinks from './CollabLinks';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const buildLink = (overrides: Record<string, unknown> = {}) => ({
id: 1,
title: 'Ferry timetable',
url: 'https://ferries.example/timetable',
pinned: 0,
...overrides,
});
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [] })),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
});
describe('CollabLinks', () => {
it('FE-COMP-LINKS-001: empty list shows the mascot state, not a bare line of text', async () => {
render(<CollabLinks tripId={1} />);
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
// The mascot carries the scene class; it is what makes this panel match its siblings.
await waitFor(() => expect(document.querySelector('.trek--links')).toBeInTheDocument());
});
it('FE-COMP-LINKS-002: the header names the panel and carries the add button', async () => {
render(<CollabLinks tripId={1} />);
const heading = await screen.findByRole('heading', { level: 3 });
expect(heading).toHaveTextContent(/links/i);
expect(heading).toHaveStyle({ textTransform: 'uppercase' });
expect(screen.getByRole('button', { name: /add link|collab\.links\.add/i })).toBeInTheDocument();
});
it('FE-COMP-LINKS-003: renders a link with its title and url', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
});
it('FE-COMP-LINKS-004: the add button opens the form in a dialog, not inside the list', async () => {
const user = userEvent.setup();
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toBeInTheDocument();
expect(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument();
});
it('FE-COMP-LINKS-005: saving posts the link and closes the form', async () => {
const user = userEvent.setup();
let posted: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/collab/links', async ({ request }) => {
posted = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ link: buildLink({ id: 7, title: 'Ferry timetable' }) });
}),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(posted).toEqual({ title: 'Ferry timetable', url: 'https://ferries.example/timetable' }));
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
});
it('FE-COMP-LINKS-006: a failed save keeps the form open so the input is not lost', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/links', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toHaveValue('Ferry timetable');
});
it('FE-COMP-LINKS-007: pin and delete are offered on a link', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByRole('button', { name: /pin link|collab\.links\.pin/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument();
});
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
// collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 99 }) });
render(<CollabLinks tripId={1} />);
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /add link|collab\.links\.add/i })).not.toBeInTheDocument();
});
});
+137 -28
View File
@@ -1,12 +1,16 @@
import { FormEvent, useEffect, useState } from 'react'
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast'
const FONT = "var(--font-system)"
interface CollabLink {
id: number
title: string
@@ -17,21 +21,91 @@ interface CollabLink {
function LinkIcon({ url, title }: { url: string; title: string }) {
const [failed, setFailed] = useState(false)
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
}
/**
* The add form as a modal, like the poll and note panels next to it. Inline it
* pushed the list down and gave the panel a second header, which is what made
* this tab read as a different app from its four siblings.
*/
function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
const { t } = useTranslation()
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const valid = title.trim().length > 0 && url.trim().length > 0
const submit = async (event: FormEvent) => {
event.preventDefault()
if (!valid || busy) return
setBusy(true)
try {
await onCreate({ title: title.trim(), url: url.trim() })
onClose()
} catch {
// add() already said what went wrong; the form stays open with the input in it.
} finally {
setBusy(false)
}
}
const field: CSSProperties = {
width: '100%', boxSizing: 'border-box', padding: '8px 12px', borderRadius: 10, fontFamily: 'inherit',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none',
border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)',
}
const label: CSSProperties = {
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
}
return createPortal(
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label htmlFor="collab-link-title" style={{ ...label, display: 'block' }}>{t('collab.links.titlePlaceholder')}</label>
<input id="collab-link-title" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={field} />
</div>
<div>
<label htmlFor="collab-link-url" style={{ ...label, display: 'block' }}>{t('collab.links.urlPlaceholder')}</label>
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={field} />
</div>
<button type="submit" disabled={!valid || busy} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: valid && !busy ? 'var(--accent)' : 'var(--border-primary)',
color: valid && !busy ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600, border: 'none', cursor: valid && !busy ? 'pointer' : 'default', fontFamily: FONT,
}}>
{busy ? '...' : t('collab.links.save')}
</button>
</div>
</form>
</div>,
document.body,
)
}
export default function CollabLinks({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const trip = useTripStore(s => s.trip)
const canEdit = useCanDo()('collab_edit', trip)
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [expanded, setExpanded] = useState(false)
const [showForm, setShowForm] = useState(false)
// 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.
const toastRef = useRef(toast)
toastRef.current = toast
const tRef = useRef(t)
tRef.current = t
useEffect(() => {
let cancelled = false
@@ -40,10 +114,10 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
.catch(err => {
if (cancelled) return
console.error('Failed to load collab links:', err)
toast.error(t('common.error'))
toastRef.current.error(tRef.current('common.error'))
})
return () => { cancelled = true }
}, [tripId, toast, t])
}, [tripId])
// Live sync, the same shape the notes panel uses. Without it the three
// collab:link events the server broadcasts would arrive nowhere and a link
@@ -73,19 +147,14 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
// Every write says something when it fails. Silence left the pin looking
// unchanged and the delete looking ignored, with an unhandled rejection in
// the console as the only trace.
const add = async (event?: FormEvent) => {
event?.preventDefault()
if (!title.trim() || !url.trim() || busy) return
setBusy(true)
const add = async (data: { title: string; url: string }) => {
try {
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
const d = await collabApi.createLink(tripId, data)
setLinks(v => [d.link, ...v])
setTitle(''); setUrl(''); setExpanded(false)
} catch (err) {
console.error('Failed to add collab link:', err)
toast.error(t('common.error'))
} finally {
setBusy(false)
throw err
}
}
@@ -108,16 +177,56 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
toast.error(t('common.error'))
}
}
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
</form>}
</div>}
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
</div>
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<Link2 size={14} color="var(--text-faint)" />
{t('collab.tabs.links')}
</h3>
{canEdit && (
<button type="button" onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.links.add')}
</button>
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
<LinkIcon url={link.url} title={link.title} />
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
</a>
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
{canEdit && <>
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
<Trash2 size={15} aria-hidden="true" />
</button>
</>}
</div>
))}
</div>
)}
</div>
{/* Create Modal */}
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
</div>
)
}
@@ -0,0 +1,95 @@
/**
* FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent.
*
* The reason this file exists: the hook used to create object URLs and enqueue
* the preview state from inside a `setFiles` updater. React invokes an updater
* an extra time in development, so one picked image produced two preview URLs
* against one file, the composer strip drew two thumbnails for one attachment,
* and removing one of them left a preview for a picture that would not be sent.
* `renderHook` under StrictMode is what reproduces it.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StrictMode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
let nextUrl = 0
const revoked: string[] = []
beforeEach(() => {
nextUrl = 0
revoked.length = 0
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => `blob:mock/${++nextUrl}`),
revokeObjectURL: vi.fn((url: string) => { revoked.push(url) }),
})
})
const img = (name: string, size = 1024) =>
new File([new Uint8Array(size)], name, { type: 'image/png' })
const strict = () => renderHook(() => useChatImages(), { wrapper: StrictMode })
describe('useChatImages', () => {
it('FE-CHATIMG-001: one picked image is one file and one preview, in StrictMode', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
expect(result.current.files).toHaveLength(1)
expect(result.current.previews).toHaveLength(1)
expect(result.current.previews[0]).toBe('blob:mock/1')
})
it('FE-CHATIMG-002: removing the only image empties both lists and revokes its URL', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
act(() => { result.current.remove(0) })
expect(result.current.files).toEqual([])
expect(result.current.previews).toEqual([])
expect(revoked).toEqual(['blob:mock/1'])
})
it('FE-CHATIMG-003: files and previews stay in step across several adds', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
act(() => { result.current.add([img('b.png'), img('c.png')]) })
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png'])
expect(result.current.previews).toEqual(['blob:mock/1', 'blob:mock/2', 'blob:mock/3'])
})
it('FE-CHATIMG-004: a rejected type is reported and nothing is pinned for it', () => {
const { result } = strict()
let ok = true
act(() => { ok = result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' })]) })
expect(ok).toBe(false)
expect(result.current.files).toEqual([])
expect(result.current.previews).toEqual([])
})
it('FE-CHATIMG-005: the cap holds and no URL is minted for what it turns away', () => {
const { result } = strict()
act(() => { result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`))) })
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
expect(result.current.previews).toHaveLength(MAX_CHAT_IMAGES)
})
it('FE-CHATIMG-006: clear revokes every URL it handed out', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png'), img('b.png')]) })
act(() => { result.current.clear() })
expect(result.current.files).toEqual([])
expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2'])
})
})
+44 -38
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
/**
* The images pinned to a chat message before it is sent.
@@ -7,16 +7,27 @@ import { useCallback, useEffect, useRef, useState } from 'react'
* their own copy of the same picking, previewing and clearing, which is two
* places for the same leak and a duplication bill on every line.
*
* Object URLs are held in a ref and revoked when the file they belong to goes,
* when the list is cleared and on unmount. Rebuilding every preview on each
* change, which is what both copies did, leaked one URL per image per keystroke
* of picking, and did it from inside a setState updater, so React ran it twice
* in development and leaked twice as fast.
* A file and the object URL that previews it are one thing, so they are stored
* as one thing and the two arrays the callers read are derived from it. Every
* mutator computes the next list first and then commits it: nothing is created
* or revoked inside a state updater, because React invokes those an extra time
* in development and the second run would mint a second URL for the same file.
* That is what the strip showed as two thumbnails for one attachment, and
* removing one of them left a preview for a picture that was never sent.
*
* The ref is the list, the state is the render of it. Written together so two
* calls in the same tick see each other, which a functional updater would give
* for free and a plain array would not.
*/
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export const MAX_CHAT_IMAGES = 4
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
interface PickedImage {
file: File
url: string
}
export interface ChatImages {
files: File[]
previews: string[]
@@ -27,51 +38,46 @@ export interface ChatImages {
}
export function useChatImages(): ChatImages {
const [files, setFiles] = useState<File[]>([])
const [previews, setPreviews] = useState<string[]>([])
const urls = useRef<string[]>([])
const [items, setItems] = useState<PickedImage[]>([])
const itemsRef = useRef<PickedImage[]>([])
const revokeAll = useCallback(() => {
urls.current.forEach(URL.revokeObjectURL)
urls.current = []
const commit = useCallback((next: PickedImage[]) => {
itemsRef.current = next
setItems(next)
}, [])
useEffect(() => revokeAll, [revokeAll])
// Unmount with pictures still pinned: the URLs outlive the component otherwise.
useEffect(() => () => {
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
itemsRef.current = []
}, [])
const add = useCallback((incoming: FileList | File[]) => {
const all = Array.from(incoming)
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
if (valid.length) {
setFiles(prev => {
const room = MAX_CHAT_IMAGES - prev.length
const added = valid.slice(0, Math.max(0, room))
// Only the new files get a URL; the ones already in the list keep theirs.
const fresh = added.map(URL.createObjectURL)
urls.current = [...urls.current, ...fresh]
setPreviews(p => [...p, ...fresh])
return [...prev, ...added]
})
const room = MAX_CHAT_IMAGES - itemsRef.current.length
const added = valid.slice(0, Math.max(0, room))
if (added.length) {
// Only the new files get a URL; the ones already in the list keep theirs.
commit([...itemsRef.current, ...added.map(file => ({ file, url: URL.createObjectURL(file) }))])
}
return valid.length === all.length
}, [])
}, [commit])
const remove = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
setPreviews(prev => {
const gone = prev[index]
if (gone) {
URL.revokeObjectURL(gone)
urls.current = urls.current.filter(u => u !== gone)
}
return prev.filter((_, i) => i !== index)
})
}, [])
const gone = itemsRef.current[index]
if (!gone) return
URL.revokeObjectURL(gone.url)
commit(itemsRef.current.filter((_, i) => i !== index))
}, [commit])
const clear = useCallback(() => {
revokeAll()
setFiles([])
setPreviews([])
}, [revokeAll])
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
commit([])
}, [commit])
const files = useMemo(() => items.map(item => item.file), [items])
const previews = useMemo(() => items.map(item => item.url), [items])
return { files, previews, add, remove, clear }
}
@@ -1,4 +1,4 @@
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-011
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-015
import React from 'react';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -160,4 +160,39 @@ describe('CollectionHero', () => {
expect(within(share).queryByText('3')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
});
// ── Export as a file (#2198) ───────────────────────────────────────────
it('FE-COMP-COLHERO-012: offers Export beside Edit and Share, in that order', async () => {
const onExport = vi.fn();
renderHero({ canEdit: true, canShare: true, isOwner: true, onExport });
const actions = ['Edit', 'Export', 'Share'].map(name => screen.getByRole('button', { name }));
// Reading order in the DOM is the reading order on screen.
const all = [...document.querySelectorAll('.col-hero-actions button')];
expect(all).toEqual(actions);
await userEvent.click(actions[1]);
expect(onExport).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLHERO-013: has no Export button where there is no list to export', () => {
renderHero({ onExport: undefined });
expect(screen.queryByRole('button', { name: 'Export' })).not.toBeInTheDocument();
});
it('FE-COMP-COLHERO-014: a viewer may export, since exporting reads and does not write', async () => {
const onExport = vi.fn();
renderHero({ canEdit: false, canShare: false, isOwner: false, onExport });
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
expect(onExport).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
it('FE-COMP-COLHERO-015: refuses a second click while the first export is still running', () => {
renderHero({ onExport: vi.fn(), exporting: true });
expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled();
});
});
@@ -1,6 +1,6 @@
import React from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { Share2, Users, Link2, Pencil } from 'lucide-react'
import { Share2, Users, Link2, Pencil, Download, Loader2 } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
@@ -26,14 +26,18 @@ interface CollectionHeroProps {
onEdit: () => void
shareMemberCount: number
onShare: () => void
/** Absent on the "All saved" pseudo-list, which is a view rather than a list. */
onExport?: () => void
exporting?: boolean
t: TranslationFn
}
/**
* The page header — a colour-washed (or cover-image) glass hero that gives the
* active list an identity: an eyebrow with the sharing state + member avatars,
* the big list name, an optional description + link chips, and a Share action
* top-right. Filtering lives in the toolbar above the places, not here.
* the big list name, an optional description + link chips, and the Edit,
* Export and Share actions top-right. Filtering lives in the toolbar above the
* places, not here.
* Modelled on the dashboard hero-trip.
*/
function linkHost(url: string): string {
@@ -42,7 +46,7 @@ function linkHost(url: string): string {
export default function CollectionHero({
eyebrow, title, color, coverImage, description, links,
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t,
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, onExport, exporting, t,
}: CollectionHeroProps): React.ReactElement {
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
const showAvatars = accepted.length > 1
@@ -94,6 +98,19 @@ export default function CollectionHero({
<span className="txt">{t('common.edit')}</span>
</button>
)}
{onExport && (
<button
type="button"
onClick={onExport}
disabled={exporting}
aria-label={t('collections.file.export')}
title={t('collections.file.exportTitle')}
className="col-glass-btn"
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
<span className="txt">{t('collections.file.export')}</span>
</button>
)}
{canShare && (
<button
type="button"
@@ -29,7 +29,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
hoverDisabled
// Hover was off here, so a wall of round photos gave no way to tell what any of
// them were without clicking. The trip map has said what a marker is all along;
// this one adds the rating, because "which of these did I like" is exactly the
// question a small picture cannot answer.
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
// No center/zoom: the map frames itself on the collection's places at mount, and
@@ -222,35 +222,12 @@ export default function CollectionPlaceDetail({
<div className="col-detail-body">
{/* Meta (view only) */}
{!editing && (place.address || navigationTargets.length > 0) && (
{!editing && place.address && (
<div className="col-detail-meta">
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
{/* Same picker as inside a trip (#1455). A saved place is somewhere
you intend to go, and until now the only way to get directions
was to add it to a trip first. */}
{navigationTargets.length > 0 && (
<>
<button
ref={navBtnRef}
type="button"
className="col-detail-nav"
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
else setNavOpen(o => !o)
}}
>
<Navigation size={12} />
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
</button>
{navOpen && (
<NavigationMenu
targets={navigationTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
/>
)}
</>
)}
</div>
)}
@@ -258,9 +235,22 @@ export default function CollectionPlaceDetail({
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
{onRate && (
<div style={{ padding: '2px 0' }}>
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
{/* Rating and labels on one line, each on its own ground.
A bare line of stars read as another row of text, and the labels tried the
cover photo first — where a blue chip on a blue sky is simply not there.
Side by side they are two badges answering two questions about the place. */}
{(onRate || assignedLabels.length > 0) && (
<div className="col-detail-badges">
{onRate && (
<div className="col-detail-rating">
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
</div>
)}
{!editing && assignedLabels.map(l => (
<span key={l.id} className="col-labelchip on static big" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
</div>
)}
@@ -334,15 +324,6 @@ export default function CollectionPlaceDetail({
</div>
) : (
<>
{assignedLabels.length > 0 && (
<div className="col-detail-labels">
{assignedLabels.map(l => (
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
</div>
)}
{place.description && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
@@ -371,6 +352,32 @@ export default function CollectionPlaceDetail({
<>
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
{/* Beside the other two rather than up by the address: getting directions is
something you DO with the place, like editing it or copying it into a
trip, and the row of things you do belongs in one place. */}
{navigationTargets.length > 0 && (
<>
<button
ref={navBtnRef}
type="button"
className="col-detail-btn"
onClick={() => {
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
else setNavOpen(o => !o)
}}
>
<Navigation size={14} />
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
</button>
{navOpen && (
<NavigationMenu
targets={navigationTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
/>
)}
</>
)}
<div className="col-detail-footer-spacer" />
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
</>
@@ -0,0 +1,144 @@
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionFile } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import ImportCollectionModal from './ImportCollectionModal';
// The modal takes `t` as a prop, so this harness forwards the real English
// translator and the assertions read as the strings a person sees.
function Harness(props: Omit<React.ComponentProps<typeof ImportCollectionModal>, 't'>) {
const { t } = useTranslation();
return <ImportCollectionModal {...props} t={t} />;
}
const listFile: CollectionFile = {
format: 'trek.collection', version: 1, name: 'Lisbon',
description: 'Worth it if you have three days',
color: '#ef4444',
labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: '#00ff00' }],
places: [{ name: 'Time Out Market' }, { name: 'Miradouro' }, { name: 'Pastéis de Belém' }],
} as CollectionFile;
function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) {
const props = {
onImport: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
/** Put a file into the hidden input the way a file picker would. */
async function choose(content: string, name = 'list.trekcollection.json') {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File([content], name, { type: 'application/json' });
// jsdom's File has no usable text(); the component awaits it.
Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) });
await userEvent.upload(input, file);
}
const importButton = () => screen.getByRole('button', { name: 'Import' });
describe('ImportCollectionModal (#2198)', () => {
it('FE-COMP-COLLIMPORT-001: opens asking for a file, with Import not yet possible', () => {
renderModal();
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
expect(screen.getByText('.trekcollection.json')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-002: shows what the file holds before anything is imported', async () => {
const props = renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Worth it if you have three days')).toBeInTheDocument());
expect(screen.getByText('3 places')).toBeInTheDocument();
expect(screen.getByText('2 labels')).toBeInTheDocument();
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
expect(importButton()).toBeEnabled();
expect(props.onImport).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIMPORT-003: leaves the label count out when the file has none', async () => {
renderModal();
await choose(JSON.stringify({ ...listFile, labels: [] }));
await waitFor(() => expect(screen.getByText('3 places')).toBeInTheDocument());
expect(screen.queryByText(/labels$/)).toBeNull();
});
it('FE-COMP-COLLIMPORT-004: imports the file under its own name', async () => {
const props = renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(importButton()).toBeEnabled());
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledTimes(1));
// No name argument: the file's own name was kept.
expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined);
});
it('FE-COMP-COLLIMPORT-005: imports under a name the person typed instead', async () => {
const props = renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
await userEvent.clear(screen.getByDisplayValue('Lisbon'));
await userEvent.type(screen.getByRole('textbox'), 'Lisbon (from Ana)');
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.anything(), 'Lisbon (from Ana)'));
});
it('FE-COMP-COLLIMPORT-006: will not import under an empty name', async () => {
renderModal();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
await userEvent.clear(screen.getByRole('textbox'));
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-007: says what is wrong with a file it cannot use', async () => {
renderModal();
await choose('not json');
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
await choose(JSON.stringify({ format: 'something.else' }));
await waitFor(() => expect(screen.getByText('That is not a TREK list file.')).toBeInTheDocument());
});
it('FE-COMP-COLLIMPORT-008: takes a good file after a bad one, and forgets the complaint', async () => {
renderModal();
await choose('not json');
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
expect(screen.queryByText('That file could not be read.')).toBeNull();
});
it('FE-COMP-COLLIMPORT-009: keeps the dialog open and says why when the import fails', async () => {
const props = renderModal({ onImport: vi.fn().mockRejectedValue(new Error('the server said no')) });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(importButton()).toBeEnabled());
await userEvent.click(importButton());
await waitFor(() => expect(screen.getByText('the server said no')).toBeInTheDocument());
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
expect(props.onClose).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIMPORT-010: closes without importing on Cancel', async () => {
const props = renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(props.onClose).toHaveBeenCalledTimes(1);
expect(props.onImport).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,171 @@
import React, { useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle } from 'lucide-react'
import Modal from '../shared/Modal'
import type { CollectionFile } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { getApiErrorMessage } from '../../types'
import { readCollectionFile, type CollectionFileError, COLLECTION_FILE_EXTENSION } from './collectionFile'
interface ImportCollectionModalProps {
onImport: (file: CollectionFile, name?: string) => Promise<void>
onClose: () => void
t: TranslationFn
}
/**
* Read a list file and make a list of it (#2198).
*
* Two steps on purpose. A file from somebody else is an unknown quantity, so
* it is read and shown first — how many places, which labels, what the list is
* called — and only then imported. The name is editable in the same breath,
* because a file called "Lisbon" from a friend is usually worth calling
* "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding
* the list editor.
*
* The file never leaves the browser as a file: it is parsed here, and what
* goes to the server is the parsed list, through the same contract the server
* validates against.
*/
export default function ImportCollectionModal({ onImport, onClose, t }: ImportCollectionModalProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<CollectionFile | null>(null)
const [name, setName] = useState('')
const [error, setError] = useState<CollectionFileError | 'failed' | null>(null)
const [failedMessage, setFailedMessage] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [dragging, setDragging] = useState(false)
const take = async (chosen: File | undefined) => {
if (!chosen) return
setError(null)
setFailedMessage(null)
const result = await readCollectionFile(chosen)
setFile(result.file)
setError(result.error)
if (result.file) setName(result.file.name)
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
void take(e.dataTransfer.files?.[0])
}
const submit = async () => {
if (!file || busy) return
setBusy(true)
setError(null)
setFailedMessage(null)
try {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
} catch (err) {
setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error')))
} finally {
setBusy(false)
}
}
const errorText = error === 'failed'
? (failedMessage ?? t('common.error'))
: error === 'too-large' ? t('collections.file.errorTooLarge')
: error === 'unreadable' ? t('collections.file.errorUnreadable')
: error === 'not-a-collection' ? t('collections.file.errorNotACollection')
: null
return (
<Modal
isOpen
onClose={onClose}
title={t('collections.file.importTitle')}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-[13px] font-medium text-content-muted hover:bg-surface-hover transition-colors">
{t('common.cancel')}
</button>
<button
type="button"
onClick={submit}
disabled={!file || busy || !name.trim()}
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('collections.file.confirm')}
</button>
</div>
}
>
<div className="flex flex-col gap-3">
<input
ref={inputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
/>
{!file ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={`flex flex-col items-center justify-center gap-2 px-4 py-10 rounded-xl border border-dashed transition-colors ${
dragging ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
<Upload size={22} className="text-content-faint" />
<span className="text-[13px] font-medium text-content">{t('collections.file.choose')}</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION}</span>
</button>
) : (
<>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card">
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: file.color || '#6366f1' }}>
<FileJson size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{file.name}</span>
<span className="flex items-center gap-3 text-[11.5px] text-content-faint">
<span className="inline-flex items-center gap-1"><MapPin size={11} />{t('collections.placeCount', { count: file.places.length })}</span>
{file.labels && file.labels.length > 0 && (
<span className="inline-flex items-center gap-1"><Tag size={11} />{t('collections.file.labelCount', { count: file.labels.length })}</span>
)}
</span>
</span>
<button type="button" onClick={() => inputRef.current?.click()} className="text-[12px] font-medium text-accent shrink-0 hover:underline">
{t('collections.file.change')}
</button>
</div>
{file.description && (
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</p>
)}
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
<p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>
</>
)}
{errorText && (
<p className="flex items-start gap-2 text-[12px] text-danger">
<AlertCircle size={13} className="shrink-0 mt-0.5" />
<span>{errorText}</span>
</p>
)}
</div>
</Modal>
)
}
@@ -35,6 +35,7 @@ function renderRail(overrides: Partial<RailProps> = {}) {
incomingInvites: [],
onSelect: vi.fn(),
onNewList: vi.fn(),
onImportList: vi.fn(),
onAcceptInvite: vi.fn(),
onDeclineInvite: vi.fn(),
...overrides,
@@ -93,7 +94,7 @@ describe('ListsRail', () => {
it('FE-COMP-LISTSRAIL-007: renders a Shared section with its lists only when there are shared lists', () => {
const { unmount } = render(<Harness {...{
ownedLists: [rome], sharedLists: [], activeId: null, incomingInvites: [],
onSelect: vi.fn(), onNewList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
onSelect: vi.fn(), onNewList: vi.fn(), onImportList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
}} />);
expect(screen.queryByText('Shared')).not.toBeInTheDocument();
unmount();
@@ -127,4 +128,18 @@ describe('ListsRail', () => {
expect(screen.queryByText('Invites')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
});
it('FE-COMP-LISTSRAIL-011: offers Import beside New list, and it opens the file dialog (#2198)', async () => {
const user = userEvent.setup();
const props = renderRail();
const importBtn = screen.getByRole('button', { name: 'Import a list from a file' });
await user.click(importBtn);
expect(props.onImportList).toHaveBeenCalledTimes(1);
expect(props.onNewList).not.toHaveBeenCalled();
// New list keeps its label; import is the icon beside it.
expect(screen.getByRole('button', { name: 'New list' })).toHaveTextContent('New list');
expect(importBtn).toHaveTextContent('');
});
});
@@ -1,5 +1,5 @@
import React from 'react'
import { Plus, Layers, Users } from 'lucide-react'
import { Plus, Layers, Users, Upload } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { ALL_SAVED } from '../../store/collectionStore'
@@ -12,6 +12,8 @@ interface ListsRailProps {
incomingInvites: IncomingCollectionInvite[]
onSelect: (id: ActiveCollectionId) => void
onNewList: () => void
/** Read a list file as a new list (#2198). */
onImportList: () => void
onAcceptInvite: (id: number) => void
onDeclineInvite: (id: number) => void
t: TranslationFn
@@ -30,7 +32,7 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
}
/**
* Left rail of the user's lists: a "New list" action, the "All saved" union
* Left rail of the user's lists: "New list" and "Import list" actions, the "All saved" union
* pseudo-list, owned lists (colour dot + count), a shared section, and an
* incoming-invites block. Selecting a list makes it active; editing / deleting
* happens from the Edit button in the hero of the active list.
@@ -38,14 +40,27 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
export default function ListsRail(props: ListsRailProps): React.ReactElement {
const {
ownedLists, sharedLists, activeId, incomingInvites,
onSelect, onNewList, onAcceptInvite, onDeclineInvite, t,
onSelect, onNewList, onImportList, onAcceptInvite, onDeclineInvite, t,
} = props
return (
<>
<button type="button" onClick={onNewList} className="col-rail-new">
<Plus size={16} /> {t('collections.newList')}
</button>
<div className="col-rail-newrow">
<button type="button" onClick={onNewList} className="col-rail-new">
<Plus size={16} /> {t('collections.newList')}
</button>
{/* Import sits beside New rather than in the hero: a file becomes a NEW
list, so it belongs where lists are made, not on the one open. */}
<button
type="button"
onClick={onImportList}
className="col-rail-new col-rail-import"
aria-label={t('collections.file.importButton')}
title={t('collections.file.importTitle')}
>
<Upload size={15} />
</button>
</div>
<div className="col-row">
<button type="button" onClick={() => onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
@@ -0,0 +1,113 @@
// FE-COLL-FILE-001 to FE-COLL-FILE-012
import { describe, it, expect, vi, afterEach } from 'vitest'
import { collectionFileName, downloadCollectionFile, readCollectionFile, COLLECTION_FILE_EXTENSION } from './collectionFile'
import type { CollectionFile } from '@trek/shared'
const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
format: 'trek.collection', version: 1, name: 'Lisbon', places: [{ name: 'Time Out Market' }], ...over,
} as CollectionFile)
/** A File whose text() the jsdom build does not provide on its own. */
function asFile(content: string, size = content.length): File {
return { size, text: () => Promise.resolve(content) } as unknown as File
}
describe('collectionFileName', () => {
it('FE-COLL-FILE-001: turns a list name into a short ascii file name', () => {
expect(collectionFileName('Lisbon')).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
expect(collectionFileName('Wochenende in Rom!')).toBe('wochenende-in-rom' + COLLECTION_FILE_EXTENSION)
expect(collectionFileName('東京')).toBe('collection' + COLLECTION_FILE_EXTENSION)
})
it('FE-COLL-FILE-002: caps a very long name rather than producing an unusable file name', () => {
const name = collectionFileName('a'.repeat(200))
expect(name).toBe('a'.repeat(40) + COLLECTION_FILE_EXTENSION)
})
})
describe('downloadCollectionFile', () => {
afterEach(() => { vi.restoreAllMocks() })
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
vi.useFakeTimers()
const revoke = vi.fn()
const created: Blob[] = []
vi.stubGlobal('URL', {
createObjectURL: (b: Blob) => { created.push(b); return 'blob:x' },
revokeObjectURL: revoke,
})
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.download).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
expect(this.href).toContain('blob:')
})
downloadCollectionFile(file())
expect(click).toHaveBeenCalledTimes(1)
expect(created[0].type).toBe('application/json')
expect(document.querySelector('a')).toBeNull()
expect(revoke).not.toHaveBeenCalled()
vi.runAllTimers()
expect(revoke).toHaveBeenCalledWith('blob:x')
vi.useRealTimers()
vi.unstubAllGlobals()
})
})
describe('readCollectionFile', () => {
it('FE-COLL-FILE-004: reads one of ours', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file())))
expect(result.error).toBeNull()
expect(result.file?.name).toBe('Lisbon')
})
it('FE-COLL-FILE-005: refuses a file too large to be one, without reading it', async () => {
const text = vi.fn()
const huge = { size: 1024 * 1024 + 1, text } as unknown as File
const result = await readCollectionFile(huge)
expect(result).toEqual({ file: null, error: 'too-large' })
expect(text).not.toHaveBeenCalled()
})
it('FE-COLL-FILE-006: says a file is unreadable when it is not JSON', async () => {
expect(await readCollectionFile(asFile('not json at all'))).toEqual({ file: null, error: 'unreadable' })
})
it('FE-COLL-FILE-007: says a JSON that is not a list is not a list', async () => {
for (const content of ['{}', '[]', 'null', '"a string"', JSON.stringify({ format: 'something.else', version: 1, name: 'x', places: [] })]) {
expect(await readCollectionFile(asFile(content)), content).toEqual({ file: null, error: 'not-a-collection' })
}
})
it('FE-COLL-FILE-008: refuses a list with no name, since the list would have none', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })))
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-009: reads an empty list, which is a list somebody has not filled yet', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))))
expect(result.error).toBeNull()
expect(result.file?.places).toEqual([])
})
it('FE-COLL-FILE-010: keeps a place the envelope does not check, for the server to judge', async () => {
// The envelope counts places; each one is validated where it is written.
const result = await readCollectionFile(asFile(JSON.stringify(file({
places: [{ name: 'Fine' }, { nonsense: true }] as never,
}))))
expect(result.error).toBeNull()
expect(result.file?.places).toHaveLength(2)
})
it('FE-COLL-FILE-011: refuses a file carrying more places than the contract allows', async () => {
const tooMany = Array.from({ length: 1001 }, (_, i) => ({ name: `Place ${i}` }))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))))
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-012: reads a file from a later version, since its additions are optional', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })))
expect(result.error).toBeNull()
expect(result.file?.version).toBe(99)
})
})
@@ -0,0 +1,74 @@
import { collectionFileSchema, MAX_COLLECTION_FILE_BYTES, type CollectionFile } from '@trek/shared'
/**
* The browser half of list export and import (#2198).
*
* The file itself is built by the server, which is also where the decision
* about what may leave the instance lives (`collection-file.schema.ts`). This
* module only hands the JSON to the browser as a download, and reads a chosen
* file back into something the import request will accept.
*
* A file that arrives here is a stranger's JSON and is treated as one: read
* locally, never uploaded, parsed rather than evaluated, size-capped before
* parsing, and put through the shared contract so a field this build does not
* know is dropped instead of travelling on to the server.
*/
export const COLLECTION_FILE_EXTENSION = '.trekcollection.json'
/** A file name from a list name: lowercase, ascii-ish, short. */
export function collectionFileName(name: string): string {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
return `${slug || 'collection'}${COLLECTION_FILE_EXTENSION}`
}
/** Hand the file to the browser as a download. */
export function downloadCollectionFile(file: CollectionFile): void {
const json = JSON.stringify(file, null, 2)
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = collectionFileName(file.name)
document.body.appendChild(a)
a.click()
a.remove()
// Revoked on the next tick: revoking synchronously can cancel the download
// in some browsers before it has started reading the blob.
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection'
/**
* Exactly one of the two is set.
*
* Two nullable fields rather than a discriminated union on an `ok` flag: the
* client compiles with `strict: false`, and without `strictNullChecks` a
* `{ok: true} | {ok: false}` union does not narrow, so every read of the
* failure branch would need a cast.
*/
export interface ParsedCollectionFile {
file: CollectionFile | null
error: CollectionFileError | null
}
/**
* Read a chosen file into a list file, or say why it is not one.
*
* The three failures are kept apart because they are three different things a
* person can do something about: a file too big to be one of ours, a file that
* is not JSON at all, and a JSON that is not a TREK list.
*/
export async function readCollectionFile(file: File): Promise<ParsedCollectionFile> {
if (file.size > MAX_COLLECTION_FILE_BYTES) return { file: null, error: 'too-large' }
let raw: unknown
try {
raw = JSON.parse(await file.text())
} catch {
return { file: null, error: 'unreadable' }
}
const parsed = collectionFileSchema.safeParse(raw)
return parsed.success
? { file: parsed.data, error: null }
: { file: null, error: 'not-a-collection' }
}
@@ -0,0 +1,527 @@
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-024
/**
* The review step between a detector's guess and a row in somebody's trip.
*
* This dialog is the only thing standing between Dawarich's visit detector and
* real data, and almost all of its behaviour is invisible until it is wrong:
* which fields are prefilled, which of them are sent back, and which are left
* out because the user did not actually change them. The server takes the body
* at face value (an accidental `name` or `date` in it silently overrides the
* recording), so "only what was corrected travels" is the contract worth
* pinning, and it is the one a refactor breaks first.
*
* The rest is the shape of the three targets. `place` needs a trip and offers a
* day, `journal` needs a journey and asks for a story, `bucket_list` needs a
* matched wish and hides the clock entirely because ticking a wish off uses the
* recorded timestamp and nothing else. Each of the three can be in a state
* where there is nothing to write to (no trips, no journeys, no matched wish),
* and in each of those the confirm button has to be inert rather than sending a
* body the server will reject with a 400.
*
* The seeding is keyed on the stay, not on the target, and one live instance
* serves all three rails. That makes two things true at once that are easy to
* get wrong together: what the user typed survives a switch from the place rail
* to the journal rail, and the trip and day they chose under the place rail
* must not travel in the journal body they then confirm.
*
* Both shells are covered: the phone gets an MSheet with its own footer, the
* desktop a Modal, and the fields in between are the same element tree. A
* regression that dropped one shell would otherwise only show up on a device.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent } from '@testing-library/react'
import type { DawarichSuggestion } from '@trek/shared'
import { render, screen } from '../../../tests/helpers/render'
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
// The one thing the dialog reads about the device. A hoisted flag rather than a
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
const phone = vi.hoisted(() => ({ value: false }))
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
/**
* A stay as the suggestions endpoint hands it over. The offsets are real ones:
* `clockOf` slices the local wall clock straight out of the string rather than
* converting it, so a fixture in UTC would quietly test a different code path
* than the one production sees.
*/
function stay(over: Partial<DawarichSuggestion> = {}): DawarichSuggestion {
return {
id: 1,
sourceVisitId: '1',
tripId: null,
tripTitle: null,
name: 'Cafe Reichard',
lat: 50.94,
lng: 6.96,
startedAt: '2026-09-10T10:15:00+02:00',
endedAt: '2026-09-10T12:40:00+02:00',
durationMinutes: 145,
localDate: '2026-09-10',
sourceStatus: 'suggested',
confidence: null,
confidenceBand: null,
state: 'new',
target: null,
acceptedPlaceId: null,
acceptedJournalEntryId: null,
acceptedBucketListItemId: null,
sourceChanged: false,
sourceMissing: false,
matchedBucketListItemId: null,
matchedBucketListName: null,
countryCode: null,
firstSeenAt: '2026-09-10T06:00:00Z',
lastSeenAt: '2026-09-10T06:00:00Z',
...over,
}
}
const TRIPS: AcceptTargetOption[] = [
{ id: 31, label: 'Rhineland' },
{ id: 32, label: 'Alps' },
]
// Distinct labels per trip so "the day list followed the trip" is observable
// without reading ids out of the DOM.
const DAYS: Record<number, AcceptTargetOption[]> = {
31: [
{ id: 401, label: 'Day 1', badge: '10 Sept' },
{ id: 402, label: 'Day 2', badge: '11 Sept' },
],
32: [{ id: 501, label: 'Alpine day', badge: '01 Oct' }],
}
const daysForTrip = (tripId: number): AcceptTargetOption[] => DAYS[tripId] ?? []
const JOURNALS: AcceptTargetOption[] = [
{ id: 71, label: 'Rhine notebook' },
{ id: 72, label: 'Alpine notebook' },
]
type Props = React.ComponentProps<typeof DawarichAcceptDialog>
function open(props: Partial<Props> = {}) {
const onConfirm = vi.fn()
const onCancel = vi.fn()
const all: Props = { suggestion: stay(), target: 'place', ...props, onConfirm, onCancel }
const view = render(<DawarichAcceptDialog {...all} />)
return {
...view,
onConfirm,
onCancel,
/** Re-render with the same props bar the ones named: the parent's own update path. */
update: (next: Partial<Props>) => view.rerender(<DawarichAcceptDialog {...all} {...next} />),
}
}
/** Open a CustomSelect by its trigger, then pick the option with this label. */
function pick(triggerName: string | RegExp, optionLabel: string): void {
fireEvent.click(screen.getByRole('button', { name: triggerName }))
fireEvent.click(screen.getByText(optionLabel))
}
/** Type into the date field's keyboard entry, which takes ISO as-is. */
function typeDate(iso: string): void {
fireEvent.click(screen.getByRole('button', { name: 'Enter date manually' }))
const input = screen.getByLabelText('Enter date manually')
fireEvent.change(input, { target: { value: iso } })
fireEvent.keyDown(input, { key: 'Enter' })
}
beforeEach(() => {
phone.value = false
})
describe('DawarichAcceptDialog', () => {
it('FE-DAWARICH-ACCEPT-001: nothing is on screen while no stay is pending', () => {
open({ suggestion: null, trips: TRIPS })
// The panel portals to document.body, so "empty container" proves nothing:
// the absence of the title and the confirm button does.
expect(screen.queryByText('Add this stay as a place')).toBeNull()
expect(screen.queryByRole('button', { name: 'Add place' })).toBeNull()
})
it('FE-DAWARICH-ACCEPT-002: every field opens prefilled from the recording', () => {
open({ trips: TRIPS, daysForTrip })
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
// The wall clock out of the offset the recording carries, not UTC.
expect(screen.getByLabelText('Arrived')).toHaveValue('10:15')
expect(screen.getByLabelText('Left')).toHaveValue('12:40')
expect(screen.getByLabelText('Notes')).toHaveValue('')
expect(screen.getByText(/^Recorded /)).toBeInTheDocument()
expect(screen.getByText('145 min')).toBeInTheDocument()
// No trip of its own, so the first trip the user may write to.
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
})
it('FE-DAWARICH-ACCEPT-003: an untouched stay sends only the target, its trip and the recorded clock', () => {
const { onConfirm } = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
// No name, no notes, no date: the server falls back to the recording for
// each of those, and repeating them would turn every accept into an edit.
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-004: every correction travels, the day included', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Café Reichard am Dom' } })
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Two hours and a lot of cake.' } })
typeDate('2026-09-12')
fireEvent.change(screen.getByLabelText('Arrived'), { target: { value: '09:00' } })
fireEvent.change(screen.getByLabelText('Left'), { target: { value: '11:45' } })
pick('Not on a day yet', 'Day 1')
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'place',
tripId: 31,
dayId: 401,
name: 'Café Reichard am Dom',
notes: 'Two hours and a lot of cake.',
date: '2026-09-12',
time: '09:00',
endTime: '11:45',
})
})
it('FE-DAWARICH-ACCEPT-005: a name or note that says nothing new is left out of the body', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
const untouched = { target: 'place', tripId: 31, time: '10:15', endTime: '12:40' }
// Emptied entirely: the recording's own name is what should survive.
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
// Retyped with padding around it: after trimming this is the same name, and
// sending it would write a "correction" nobody made.
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Cafe Reichard ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
})
it('FE-DAWARICH-ACCEPT-006: unplanned is an answer, not a missing one', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
pick('Not on a day yet', 'Day 1')
// The date rides along as the trigger's badge rather than inside its label.
expect(screen.getByRole('button', { name: /Day 1/ })).toHaveTextContent('10 Sept')
// Back to "not on a day yet": the sentinel option has to clear the choice
// rather than be stored as a day id of its own.
pick(/Day 1/, 'Not on a day yet')
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-007: changing the trip drops the day that belonged to the old one', () => {
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
pick('Not on a day yet', 'Day 2')
pick('Rhineland', 'Alps')
// Day 2 is a day of the Rhineland trip; keeping it would attach the place to
// somebody else's day, which the server refuses with a 400.
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
expect(screen.getByText('Alpine day')).toBeInTheDocument()
expect(screen.queryByText('Day 2')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-008: the trip falls back from the stay to the first writable one to nothing', () => {
// The stay's own trip wins: it is the one whose dates the recording fell into.
const own = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
own.unmount()
const first = open({ trips: TRIPS, daysForTrip })
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
first.unmount()
// No trip at all: the picker admits it and the confirm is inert, because a
// place with no trip is a 400 rather than a partial success.
open({ trips: [], daysForTrip })
expect(screen.getByRole('button', { name: 'Choose a trip' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
})
it('FE-DAWARICH-ACCEPT-009: with no day resolver the day picker offers only "not on a day yet"', () => {
// The trip rail passes a resolver; an Atlas-side or MCP-shaped caller need
// not, and the picker has to survive that rather than throw.
open({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
expect(screen.getAllByText('Not on a day yet')).toHaveLength(2)
expect(screen.queryByText('Day 1')).toBeNull()
})
it('FE-DAWARICH-ACCEPT-010: the journal target asks for a story and sends the chosen journey', () => {
const { onConfirm } = open({ target: 'journal', journals: JOURNALS })
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
// A story gets room to be one; notes on a place do not need five rows.
expect(screen.getByLabelText('Your story')).toHaveAttribute('rows', '5')
expect(screen.getByText('Add photos to the entry after it is created.')).toBeInTheDocument()
// No trip or day rail here: the entry belongs to a journey, not to a day.
expect(screen.queryByText('Day')).toBeNull()
expect(screen.queryByText('Trip')).toBeNull()
fireEvent.change(screen.getByLabelText('Your story'), { target: { value: 'Cake, then the cathedral.' } })
pick('Rhine notebook', 'Alpine notebook')
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'journal',
journalId: 72,
notes: 'Cake, then the cathedral.',
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-011: with no journey to write to, the journal confirm is inert', () => {
open({ target: 'journal' })
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
})
it('FE-DAWARICH-ACCEPT-012: ticking a wish off states the rule and sends the matched wish', () => {
const { onConfirm } = open({
target: 'bucket_list',
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
})
expect(screen.getByRole('heading', { name: 'Tick off a wish' })).toBeInTheDocument()
// Why a stay at a café is being offered against a museum: the rule, spelled out.
expect(screen.getByText(/On your wishlist: Museum Ludwig/)).toHaveTextContent('within 250 m and after 20 minutes')
// Nothing here is editable but the wish it ticks off: the visit's own
// timestamp is what gets stored, so a clock or a note would be theatre.
expect(screen.queryByLabelText('Arrived')).toBeNull()
expect(screen.queryByLabelText('Left')).toBeNull()
expect(screen.queryByLabelText('Notes')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'bucket_list',
bucketListItemId: 88,
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-013: a stay that matched no wish cannot be ticked off', () => {
open({ target: 'bucket_list', suggestion: stay() })
expect(screen.getByRole('button', { name: 'Tick it off' })).toBeDisabled()
expect(screen.queryByText(/On your wishlist/)).toBeNull()
})
it('FE-DAWARICH-ACCEPT-014: a write already in flight blocks a second one', () => {
// Everything is answered, so only `busy` can be holding the button, which is
// the point: double-accepting a stay creates two places out of one recording.
open({ trips: TRIPS, daysForTrip, busy: true })
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
})
it('FE-DAWARICH-ACCEPT-015: backing out writes nothing, by button or by Escape', () => {
const { onCancel, onConfirm } = open({ trips: TRIPS, daysForTrip })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(2)
expect(onConfirm).not.toHaveBeenCalled()
})
it("FE-DAWARICH-ACCEPT-016: the source's own verdict is shown only while it is unconfirmed", () => {
const unconfirmed = open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'suggested' }) })
expect(screen.getByText('Detected, unconfirmed')).toBeInTheDocument()
unconfirmed.unmount()
// Confirmed in Dawarich: saying so would be noise, the badge is simply gone.
open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'confirmed' }) })
expect(screen.queryByText('Detected, unconfirmed')).toBeNull()
})
it('FE-DAWARICH-ACCEPT-017: only the bands Dawarich actually emits are shown, and only "low" warns', () => {
const low = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'low', confidence: 20 }) })
expect(screen.getByText('Uncertain detection').parentElement?.className).toContain('text-warning')
low.unmount()
const high = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'high', confidence: 90 }) })
// A confident detection is a fact, not a warning. Same badge, neutral tone.
expect(screen.getByText('Confident detection').parentElement?.className).toContain('text-content-secondary')
high.unmount()
const none = open({ trips: TRIPS, suggestion: stay({ confidenceBand: null }) })
expect(screen.queryByText(/detection$/)).toBeNull()
none.unmount()
// A band a later Dawarich invents: nothing at all, rather than a raw key.
open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'certain', confidence: 100 }) })
expect(screen.queryByText(/dawarich\.confidence/)).toBeNull()
expect(screen.queryByText(/detection$/)).toBeNull()
})
it('FE-DAWARICH-ACCEPT-018: a recording with no readable clock or date sends neither', () => {
// Dawarich has shipped date-only and non-ISO timestamps; the clock parser
// answers with an empty string rather than a guess, and an empty string must
// not reach the body as `time: ""`.
const { onConfirm } = open({
trips: TRIPS,
daysForTrip,
suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }),
})
expect(screen.getByLabelText('Arrived')).toHaveValue('')
expect(screen.getByLabelText('Left')).toHaveValue('')
expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31 })
})
it('FE-DAWARICH-ACCEPT-019: the fields follow the stay, and survive a re-render of the same one', () => {
const { update } = open({ trips: TRIPS, daysForTrip })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Edited by hand' } })
// A fresh object for the same row: a parent re-render, not a new decision.
// Re-seeding here would eat what the user typed mid-sentence.
update({ suggestion: stay() })
expect(screen.getByLabelText('Name')).toHaveValue('Edited by hand')
update({
suggestion: stay({
id: 2,
name: 'Kölner Dom',
tripId: 32,
localDate: '2026-09-11',
startedAt: '2026-09-11T08:00:00+02:00',
endedAt: '2026-09-11T09:30:00+02:00',
}),
})
expect(screen.getByLabelText('Name')).toHaveValue('Kölner Dom')
expect(screen.getByLabelText('Arrived')).toHaveValue('08:00')
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
})
it('FE-DAWARICH-ACCEPT-020: on a phone it is a sheet, and its footer confirms', () => {
phone.value = true
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
expect(screen.getByRole('dialog', { name: 'Add this stay as a place' })).toBeInTheDocument()
// The desktop modal's own heading is the tell that the wrong shell rendered.
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Reichard' } })
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'place',
tripId: 31,
name: 'Reichard',
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-021: the phone sheet backs out from its header and its footer, and blocks an impossible write', () => {
phone.value = true
const { onCancel, onConfirm } = open({ target: 'journal' })
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
// Two ways out of the sheet, both the same door: the header's close circle
// and the footer's cancel pill.
const exits = screen.getAllByRole('button', { name: 'Cancel' })
expect(exits).toHaveLength(2)
exits.forEach(button => fireEvent.click(button))
expect(onCancel).toHaveBeenCalledTimes(2)
expect(onConfirm).not.toHaveBeenCalled()
})
it('FE-DAWARICH-ACCEPT-022: the name stays editable on the wishlist rail, and a correction travels', () => {
// The clock, the date and the notes are all hidden for a wish, which is
// exactly why the one field that survives is worth naming: the detector's
// guess at a place name is what a tick-off is filed under, and "hide
// everything that is not the wish" is the tidy-up that would take it away.
const { onConfirm } = open({
target: 'bucket_list',
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
})
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Museum Ludwig' } })
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
expect(onConfirm).toHaveBeenCalledWith({
target: 'bucket_list',
bucketListItemId: 88,
name: 'Museum Ludwig',
time: '10:15',
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-023: a new stay arrives with no day chosen, whatever the last one had', () => {
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
pick('Not on a day yet', 'Day 2')
// Same trip, so the day list does not change and a stale `dayId` would stay
// a legal value, which is the dangerous version of this bug: the second
// stay is filed silently on the day somebody picked for the first.
update({ suggestion: stay({ id: 3, name: 'Kölner Dom', tripId: 31 }) })
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
})
it('FE-DAWARICH-ACCEPT-024: switching rails keeps what was typed and leaks no trip into the journal body', () => {
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Cake, then the cathedral.' } })
pick('Not on a day yet', 'Day 1')
// Backing out of "add as a place" and choosing "write a journal entry" on
// the same row is one instance and one stay, so nothing is re-seeded.
update({ target: 'journal', journals: JOURNALS })
expect(screen.getByLabelText('Your story')).toHaveValue('Cake, then the cathedral.')
// The journal was never chosen, because the seeding runs off the stay and
// the stay has not changed. Offering a confirm here would send a body with
// no journalId in it.
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
pick('Choose a journal', 'Rhine notebook')
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
// Trip 31 and day 401 are still held in state. A journal entry belongs to a
// journey, and the server reads the body at face value.
expect(onConfirm).toHaveBeenCalledWith({
target: 'journal',
journalId: 71,
notes: 'Cake, then the cathedral.',
time: '10:15',
endTime: '12:40',
})
})
})
@@ -0,0 +1,387 @@
import React, { useMemo, useState } from 'react'
import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import Modal from '../shared/Modal'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
import { useIsPhone } from '../../mobile/useIsPhone'
import DawarichIcon from '../shared/DawarichIcon'
import DawarichBadge from './DawarichBadge'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import CustomSelect from '../shared/CustomSelect'
export interface AcceptTargetOption {
id: number
label: string
/** Secondary half of the label — a day's date beside its number. */
badge?: string
}
export interface DawarichAcceptDialogProps {
suggestion: DawarichSuggestion | null
target: DawarichSuggestionTarget
/** Trips the user may write to, for the `place` target. */
trips?: AcceptTargetOption[]
/** Days of the chosen trip, resolved by the caller. */
daysForTrip?: (tripId: number) => AcceptTargetOption[]
/** Journeys the user may edit, for the `journal` target. */
journals?: AcceptTargetOption[]
busy?: boolean
onCancel: () => void
onConfirm: (body: DawarichAccept) => void
}
/**
* The review step: see what was recorded, correct it, then decide where it goes.
*
* The whole reason the integration is not a one-click import. A visit detector
* is right most of the time and confidently wrong the rest, and the difference
* between the two is a judgement only the traveller can make — so the values
* are prefilled from the recording and every one of them is editable before
* anything is written.
*
* What it does NOT do is offer to edit the recording. Dawarich owns that, and
* a correction made here would be lost the next time the detector ran.
*
* Three targets, because `DawarichSuggestionTarget` has three. The trip and
* journal rails open the first two; `bucket_list` is reached through MCP, where
* an assistant can accept a stay against a wish. The branch is kept for that and
* for the day an Atlas-side entry point wants it — the server has always been
* able to do it, and a dialog that could only handle two of three targets would
* be the thing that had to be fixed then.
*/
export default function DawarichAcceptDialog({
suggestion,
target,
trips = [],
daysForTrip,
journals = [],
busy = false,
onCancel,
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const phone = useIsPhone()
const [name, setName] = useState('')
const [notes, setNotes] = useState('')
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [endTime, setEndTime] = useState('')
const [tripId, setTripId] = useState<number | ''>('')
const [dayId, setDayId] = useState<number | ''>('')
const [journalId, setJournalId] = useState<number | ''>('')
// Re-seeded whenever a different suggestion opens the dialog, so the fields
// always describe the row the user just pressed rather than the previous one.
const [seededFor, setSeededFor] = useState<number | null>(null)
if (suggestion && seededFor !== suggestion.id) {
setSeededFor(suggestion.id)
setName(suggestion.name)
setNotes('')
setDate(suggestion.localDate)
setTime(clockOf(suggestion.startedAt))
setEndTime(clockOf(suggestion.endedAt))
setTripId(suggestion.tripId ?? (trips[0]?.id ?? ''))
setDayId('')
setJournalId(journals[0]?.id ?? '')
}
const days = useMemo(
() => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []),
[tripId, daysForTrip],
)
if (!suggestion) return null
const missingTarget =
(target === 'place' && typeof tripId !== 'number') ||
(target === 'journal' && typeof journalId !== 'number') ||
(target === 'bucket_list' && suggestion.matchedBucketListItemId === null)
const confirm = () => {
const body: DawarichAccept = { target }
if (target === 'place') {
if (typeof tripId === 'number') body.tripId = tripId
if (typeof dayId === 'number') body.dayId = dayId
}
if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId
if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) {
body.bucketListItemId = suggestion.matchedBucketListItemId
}
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
if (notes.trim()) body.notes = notes.trim()
if (date && date !== suggestion.localDate) body.date = date
if (time) body.time = time
if (endTime) body.endTime = endTime
onConfirm(body)
}
// The same fields in both shells — only the frame around them differs.
const fields = (
<div className="space-y-4">
{/* What was recorded, before anything the user changed. Kept visible so a
corrected field can be compared against the source rather than
replacing it invisibly. */}
{/* What Dawarich recorded, as facts rather than as a sentence with
separators in it: a line for when, and a row of the same badges the
review list uses so the dialog and the list read as one thing. */}
<div className="rounded-xl border p-3 border-edge bg-surface-secondary">
<div className="flex items-center gap-2">
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
<DawarichIcon size={20} />
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
<DawarichBadge icon={Hourglass} size="sm">
{t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })}
</DawarichBadge>
{/* The source's own verdict, shown here rather than on every row of
the list: since Dawarich 1.12.0 "unconfirmed" is the normal state
of a visit, so it says nothing in a list and something here, where
somebody is deciding whether to trust this one. */}
{suggestion.sourceStatus === 'suggested' && (
<DawarichBadge icon={CircleDashed} size="sm">{t('dawarich.sourceStatus.suggested')}</DawarichBadge>
)}
{KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && (
<DawarichBadge
icon={Gauge}
size="sm"
tone={suggestion.confidenceBand === 'low' ? 'warning' : 'neutral'}
>
{t('dawarich.confidence.' + suggestion.confidenceBand)}
</DawarichBadge>
)}
</div>
</div>
{/* Why this stay is being offered against a wish at all. The row that
led here shows the wish's name and nothing else, which leaves the
reader to guess what a star beside a café has to do with a museum —
it is the rule, and the rule is short enough to state. */}
{target === 'bucket_list' && suggestion.matchedBucketListName && (
<p className="flex items-start gap-1.5 text-caption text-content-muted">
<Star className="w-3 h-3 flex-shrink-0 mt-0.5 text-accent-on" />
<span>
{t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })}
{' — '}
{t('dawarich.bucket.rule', {
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
})}
</span>
</p>
)}
<div>
<label htmlFor="dawarich-accept-name" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.name')}
</label>
<input
id="dawarich-accept-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
/>
</div>
{/* TREK's own pickers, not the browser's. A native `type="date"` paints
itself from the OS locale and a native `type="time"` ignores the 12h/24h
setting outright (#2067) — and neither follows the colour scheme. The
date column is the wide one because a localized date is long and a
clock never is. */}
{target !== 'bucket_list' && (
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.date')}
</span>
<CustomDatePicker value={date} onChange={setDate} />
</div>
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.from')}
</span>
<CustomTimePicker value={time} onChange={setTime} aria-label={t('dawarich.accept.from')} />
</div>
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.to')}
</span>
<CustomTimePicker value={endTime} onChange={setEndTime} aria-label={t('dawarich.accept.to')} />
</div>
</div>
)}
{/* Stacked and full width, in the order the choice is actually made: the
day is what somebody is deciding, the trip is usually already right.
Side by side, both pickers were half as wide as their longest option
and the menus opened narrower than the text in them. */}
{target === 'place' && (
<div className="space-y-3">
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.day')}
</span>
{/* Unplanned is a real answer, not a missing one: half the places on
a trip sit on no day, and forcing a choice here would invent one. */}
<CustomSelect
value={dayId === '' ? NO_DAY : dayId}
onChange={next => setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)}
options={[
{ value: NO_DAY, label: t('dawarich.accept.noDay') },
// The date rides as the option's badge rather than inside its
// label: the day number is what people scan for, and the date
// is the confirmation they read second.
...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })),
]}
style={{ width: '100%' }}
/>
</div>
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.trip')}
</span>
<CustomSelect
value={tripId}
onChange={next => { setTripId(typeof next === 'number' ? next : ''); setDayId('') }}
options={trips.map(trip => ({ value: trip.id, label: trip.label }))}
placeholder={t('dawarich.accept.pickTrip')}
style={{ width: '100%' }}
/>
</div>
</div>
)}
{target === 'journal' && (
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.journal')}
</span>
<CustomSelect
value={journalId}
onChange={next => setJournalId(typeof next === 'number' ? next : '')}
options={journals.map(journal => ({ value: journal.id, label: journal.label }))}
placeholder={t('dawarich.accept.pickJournal')}
style={{ width: '100%' }}
/>
</div>
)}
{target !== 'bucket_list' && (
<div>
<label htmlFor="dawarich-accept-notes" className="block text-caption font-medium mb-1.5 text-content-secondary">
{target === 'journal' ? t('dawarich.accept.story') : t('dawarich.accept.notes')}
</label>
<textarea
id="dawarich-accept-notes"
value={notes}
onChange={e => setNotes(e.target.value)}
rows={target === 'journal' ? 5 : 3}
placeholder={t('dawarich.accept.storyPlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-body resize-none focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
/>
{target === 'journal' && (
<p className="mt-1 text-caption text-content-muted">{t('dawarich.accept.photosHint')}</p>
)}
</div>
)}
</div>
)
const title = t(`dawarich.accept.title.${target}`)
// The phone shell has its own sheet, its own rounding and its own footer pill,
// and every other form on it uses them. A desktop modal scaled down would be
// the one dialog in the app that came from somewhere else.
if (phone) {
return (
<MSheet open onClose={onCancel} variant="bottom" material="glass" ariaLabel={title}>
<div className="flex max-h-[88dvh] min-h-0 flex-col">
<FormSheetHeader
// The mark fills the tile edge to edge, rounded like every other
// avatar-sized badge in the shell — a small logo floating in a grey
// square reads as a placeholder.
leading={
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
<DawarichIcon size={40} />
</span>
}
title={title}
onClose={onCancel}
closeLabel={t('common.cancel')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{fields}</div>
<FormSheetFooter
onCancel={onCancel}
cancelLabel={t('common.cancel')}
onSubmit={confirm}
submitLabel={t(`dawarich.accept.confirm.${target}`)}
submitDisabled={busy || missingTarget}
/>
</div>
</MSheet>
)
}
return (
<Modal
isOpen
onClose={onCancel}
size="lg"
title={title}
footer={
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg text-body border border-edge text-content-secondary"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={confirm}
disabled={busy || missingTarget}
className="px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
{t(`dawarich.accept.confirm.${target}`)}
</button>
</div>
}
>
{fields}
</Modal>
)
}
/**
* The bands Dawarich actually emits (>=70 high, >=40 medium, else low).
* A band a later version adds is shown as nothing rather than as a raw English
* word or a missing translation key.
*/
const KNOWN_BANDS = ['high', 'medium', 'low']
/**
* "No day" as a real option value.
*
* CustomSelect uses strict equality on its values and treats the empty string as
* "nothing chosen", which would make the placeholder and the deliberate answer
* indistinguishable — and unplanned is a deliberate answer here.
*/
const NO_DAY = '__none__'
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
function clockOf(iso: string): string {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
}
@@ -0,0 +1,637 @@
// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-029
/**
* The room where Dawarich's two Atlas questions get answered.
*
* Nothing in here runs on its own, and that is the whole design: the Atlas is a
* curated list that carries tombstones precisely so a country somebody removed
* stays removed, so every read and every write starts with a person pressing a
* button. What is worth pinning is therefore not "does it fetch" but everything
* arranged around the fetch.
*
* - **What is written, and dated when.** A wish is ticked off on the day it was
* reached, not on today, and wishes reached on the same day travel in one
* request. A regression here either back-dates somebody's list wrongly or
* turns one confirmation into twenty requests.
* - **The five states each half can be in.** Unasked, in flight, answered,
* answered with nothing, answered with caveats. The confirm button is
* rendered or absent rather than merely greyed out, so each state has to
* decide whether that control exists at all, and the caveats under the list
* are only said when there is something to say.
* - **Refusing to write blind.** Offline is not a server error: nothing was
* sent, and "something went wrong talking to Dawarich" for a train tunnel
* sends people hunting for a bug in their own instance.
* - **Both shells.** The phone gets an MSheet with its own footer, the desktop
* a Modal; the body between them is the same tree, so a regression that
* dropped one shell would otherwise only surface on a device.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '../../../tests/helpers/render'
import { act, fireEvent } from '@testing-library/react'
import { setForcedOffline } from '../../sync/networkMode'
import { DawarichOfflineError } from '../../repo/dawarichRepo'
import DawarichAtlasDialog from './DawarichAtlasDialog'
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
vi.mock('../shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
const repo = {
bucketScan: vi.fn(),
atlasSuggestions: vi.fn(),
}
vi.mock('../../repo/dawarichRepo', () => ({
dawarichRepo: {
bucketScan: (...args: unknown[]) => repo.bucketScan(...args),
atlasSuggestions: (...args: unknown[]) => repo.atlasSuggestions(...args),
},
DawarichOfflineError: class extends Error {},
}))
const api = {
confirmBucketVisits: vi.fn(),
acceptAtlasCountries: vi.fn(),
}
vi.mock('../../api/dawarich', () => ({
dawarichApi: {
confirmBucketVisits: (...args: unknown[]) => api.confirmBucketVisits(...args),
acceptAtlasCountries: (...args: unknown[]) => api.acceptAtlasCountries(...args),
},
}))
// The one thing the dialog reads about the device. A hoisted flag rather than a
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
const phone = vi.hoisted(() => ({ value: false }))
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
const SCAN = {
matches: [
{
itemId: 11,
name: 'Museum Ludwig',
match: { at: '2026-09-10T14:00:00Z', minutes: 145, distanceMeters: 49, points: 60 },
alreadyVisited: false,
},
{
itemId: 12,
name: 'Kölner Dom',
match: { at: '2026-09-09T14:00:00Z', minutes: 30, distanceMeters: 80, points: 12 },
alreadyVisited: true,
},
{ itemId: 13, name: 'Rheinpark', match: null, alreadyVisited: false },
],
skippedWithoutCoordinates: 2,
truncated: false,
fetchedAt: '2026-09-12T10:00:00Z',
}
const COUNTRIES = {
countries: [
{ countryCode: 'DE', sourceName: 'Germany', cities: [{ name: 'Cologne', minutes: 400, lastSeenAt: '2026-09-10T10:00:00Z' }], alreadyVisited: true },
{ countryCode: 'NL', sourceName: 'Netherlands', cities: [{ name: 'Maastricht', minutes: 90, lastSeenAt: '2026-09-08T10:00:00Z' }], alreadyVisited: false },
],
unresolved: ['Freedonia'],
fetchedAt: '2026-09-12T10:00:00Z',
}
/**
* Three wishes reached on two days. The pair sharing a timestamp is the whole
* point of the fixture: the dialog groups by the date it is about to write, and
* a regression that lost the grouping would send one request per wish. The
* third one sits far enough away to be reported in kilometres rather than
* metres, which is the other half of the row's arithmetic.
*/
const SAME_DAY = {
matches: [
{
itemId: 21,
name: 'Van Gogh Museum',
match: { at: '2026-08-02T11:00:00Z', minutes: 95, distanceMeters: 30, points: 40 },
alreadyVisited: false,
},
{
itemId: 22,
name: 'Rijksmuseum',
match: { at: '2026-08-02T11:00:00Z', minutes: 120, distanceMeters: 55, points: 51 },
alreadyVisited: false,
},
{
itemId: 23,
name: 'Kinderdijk',
match: { at: '2026-07-30T09:00:00Z', minutes: 70, distanceMeters: 1400, points: 22 },
alreadyVisited: false,
},
],
skippedWithoutCoordinates: 0,
truncated: false,
fetchedAt: '2026-09-12T10:00:00Z',
}
/**
* A reading with nothing to apologise for: no unmatched names, one country with
* more cities than fit beside a flag and one with none at all.
*/
const MORE_COUNTRIES = {
countries: [
{
countryCode: 'FR',
sourceName: 'France',
cities: ['Paris', 'Lyon', 'Nice', 'Nantes'].map(name => ({
name,
minutes: 120,
lastSeenAt: '2026-08-02T10:00:00Z',
})),
alreadyVisited: false,
},
{ countryCode: 'BE', sourceName: 'Belgium', cities: [], alreadyVisited: false },
],
unresolved: [],
fetchedAt: '2026-09-12T10:00:00Z',
}
/** A promise this test settles by hand, to pin down what is on screen mid-flight. */
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(res => {
resolve = res
})
return { promise, resolve }
}
function open(props: Partial<React.ComponentProps<typeof DawarichAtlasDialog>> = {}) {
return render(<DawarichAtlasDialog isOpen onClose={vi.fn()} {...props} />)
}
beforeEach(() => {
phone.value = false
setForcedOffline(false)
toast.success.mockReset()
toast.error.mockReset()
repo.bucketScan.mockReset().mockResolvedValue(SCAN)
repo.atlasSuggestions.mockReset().mockResolvedValue(COUNTRIES)
api.confirmBucketVisits.mockReset().mockResolvedValue({ updated: 1 })
api.acceptAtlasCountries.mockReset().mockResolvedValue({ marked: 1 })
})
// Force-offline is module state, not component state: left on, it would leak
// into the next file's writes.
afterEach(() => setForcedOffline(false))
describe('DawarichAtlasDialog', () => {
it('FE-DAWARICH-ATLASDLG-001: asks nothing on its own — the scan runs when the reader says so', () => {
open()
expect(repo.bucketScan).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-002: offers no confirmation before there is anything to confirm', () => {
open()
// Hidden until a result is on screen rather than merely disabled: a greyed
// out "Tick off 0" is a dead control asking to be understood, and `hidden`
// keeps it out of the accessibility tree too.
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
})
it('FE-DAWARICH-ATLASDLG-003: a scan lists what it found, with the distance, the time and the day', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
const row = screen.getByRole('checkbox', { name: 'Museum Ludwig' })
expect(row.textContent).toContain('49 m away')
expect(row.textContent).toContain('2 h 25 min')
// A wish with no match at all is not a row — there is nothing to decide.
expect(screen.queryByRole('checkbox', { name: 'Rheinpark' })).not.toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-004: open wishes start selected, already ticked ones do not', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toBeDisabled()
})
it('FE-DAWARICH-ATLASDLG-005: unticking a row takes it out of what gets written', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
expect(screen.getByRole('button', { name: /Tick off 0/ })).toBeDisabled()
})
it('FE-DAWARICH-ATLASDLG-006: confirming writes exactly the picked wishes and reports back', async () => {
const onChanged = vi.fn()
open({ onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
// With the date the stay was actually reached on — a wish from last year
// ticked off with today's date is a wrong entry in a list people keep.
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
expect(onChanged).toHaveBeenCalled()
// Marked in place rather than rescanned — one upstream request per wish is
// not worth spending to tell the reader what they just did.
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeDisabled())
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASDLG-007: the countries tab offers only what the Atlas does not already have', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
expect(screen.queryByRole('checkbox', { name: 'Germany' })).not.toBeInTheDocument()
// A country Dawarich named but TREK could not code is said out loud, not dropped.
expect(screen.getByText(/Freedonia/)).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-008: confirming countries sends their codes', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
})
it('FE-DAWARICH-ATLASDLG-009: a scan that fails says so and leaves the dialog usable', async () => {
repo.bucketScan.mockRejectedValue({ response: { data: { error: 'Dawarich rejected the API key.' } } })
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.'))
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-010: while the recordings are being read there are placeholders and nothing to press', async () => {
const scan = deferred<typeof SCAN>()
repo.bucketScan.mockReturnValueOnce(scan.promise)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
expect(screen.getByText('Checking…')).toBeInTheDocument()
expect(screen.queryByRole('checkbox')).toBeNull()
// Neither control exists yet: there is nothing to confirm, and "check again"
// before the first answer is a second request for the same thing.
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
expect(screen.queryByRole('button', { name: 'Check again' })).toBeNull()
await act(async () => {
scan.resolve(SCAN)
})
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
})
it('FE-DAWARICH-ATLASDLG-011: a scan that found nothing says so in a sentence and offers no confirmation', async () => {
repo.bucketScan.mockResolvedValue({ ...SCAN, matches: [], skippedWithoutCoordinates: 0 })
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() =>
expect(screen.getByText('Nothing on your wishlist turned up in your recordings.')).toBeInTheDocument(),
)
// Absent, not greyed out: a dead "Tick off 0" under an empty state only asks
// to be understood.
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
// The rule stays on screen, because it is the answer to "why did nothing
// turn up": driving past a wish does not count as reaching it.
expect(screen.getByText(/A wish counts as reached within 250 m and after 20 minutes/)).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-012: the caveats under a scan are only said when there are any', async () => {
repo.bucketScan.mockResolvedValue({ ...SCAN, skippedWithoutCoordinates: 0, truncated: true })
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() =>
expect(screen.getByText('Only the first entries were checked. Run it again for the rest.')).toBeInTheDocument(),
)
// Nothing was skipped this time, so nothing is said about skipping: a
// "0 entries have no coordinates" line is noise dressed as information.
expect(screen.queryByText(/have no coordinates/)).toBeNull()
})
it('FE-DAWARICH-ATLASDLG-013: a country shows the first of its cities, one without any shows none', async () => {
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'France' })).toBeInTheDocument())
const france = screen.getByRole('checkbox', { name: 'France' })
// The badge counts all four; the line beside it names three, which is what
// fits beside a flag at the width a phone gives this dialog.
expect(france.textContent).toContain('4 cities')
expect(france.textContent).toContain('Paris, Lyon, Nice')
expect(france.textContent).not.toContain('Nantes')
expect(screen.getByRole('checkbox', { name: 'Belgium' }).textContent).not.toContain('cities')
// Everything matched, so the dialog does not mention matching at all.
expect(screen.queryByText(/could not match these to a country/)).toBeNull()
expect(screen.getByText('The last 12 months were looked at.')).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-014: a reading with nothing new in it says so rather than showing an empty list', async () => {
// Germany alone, and the Atlas already has it.
repo.atlasSuggestions.mockResolvedValue({ ...COUNTRIES, countries: [COUNTRIES.countries[0]] })
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() =>
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
)
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
})
it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => {
repo.bucketScan.mockResolvedValue(SAME_DAY)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Rijksmuseum' })).toBeInTheDocument())
// Over a kilometre out is said in kilometres: "1400 m away" is a
// measurement, "1.4 km away" is how far it was.
expect(screen.getByRole('checkbox', { name: 'Kinderdijk' }).textContent).toContain('1.4 km away')
fireEvent.click(screen.getByRole('button', { name: /Tick off 3/ }))
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(2))
// Grouped by date, not ordered by it: the row order inside a day is the
// sort's business and nothing here depends on it.
const calls = api.confirmBucketVisits.mock.calls as Array<[number[], string]>
const byDate = new Map(calls.map(([ids, at]) => [at, [...ids].sort((a, b) => a - b)]))
expect(byDate.get('2026-08-02T11:00:00Z')).toEqual([21, 22])
expect(byDate.get('2026-07-30T09:00:00Z')).toEqual([23])
// Both answers counted, not just the last one to come back.
expect(toast.success).toHaveBeenCalledWith('2 wishes ticked off')
})
it('FE-DAWARICH-ATLASDLG-016: an unticked wish is left out of the write entirely', async () => {
repo.bucketScan.mockResolvedValue(SAME_DAY)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Kinderdijk' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('checkbox', { name: 'Kinderdijk' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Rijksmuseum' }))
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
// One request, for one day, carrying one id. The day whose other wish was
// dropped does not travel half-empty, and the other day does not travel at all.
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(1))
expect(api.confirmBucketVisits).toHaveBeenCalledWith([21], '2026-08-02T11:00:00Z')
})
it('FE-DAWARICH-ATLASDLG-017: a row that was unticked can be put back', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'false')
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
})
it('FE-DAWARICH-ATLASDLG-018: offline, a wish confirmation is refused before anything is sent', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
setForcedOffline(true)
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
// Not attempted at all: a confirmation fired into a dead connection hangs to
// a timeout and then reports a server error for something no server saw.
expect(api.confirmBucketVisits).not.toHaveBeenCalled()
// And the selection survives, so pressing it again once there is signal is enough.
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
})
it('FE-DAWARICH-ATLASDLG-019: offline, a country confirmation is refused before anything is sent', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
setForcedOffline(true)
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
expect(api.acceptAtlasCountries).not.toHaveBeenCalled()
})
it('FE-DAWARICH-ATLASDLG-020: a write that failed leaves every row exactly as it was', async () => {
// A rejection with no `{ error }` envelope at all: a socket that died
// mid-request rather than a server that answered.
api.confirmBucketVisits.mockRejectedValue(new Error('socket hang up'))
const onChanged = vi.fn()
open({ onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
// Nothing was marked off locally, so a second attempt still has something to
// send, and the Atlas is not asked to re-read for a write that never landed.
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeEnabled()
expect(onChanged).not.toHaveBeenCalled()
})
it('FE-DAWARICH-ATLASDLG-021: a refusal carrying no readable reason still gets a sentence', async () => {
// An `{ error }` that is not a string is not a message: falling through to
// the generic sentence beats printing "[object Object]" at somebody.
api.acceptAtlasCountries.mockRejectedValue({ response: { data: { error: { code: 502 } } } })
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true')
})
it('FE-DAWARICH-ATLASDLG-022: a read that never left the device reads as a connection, not a failure', async () => {
// The repo refuses offline reads itself, and its refusal has to survive the
// trip through failureText as the connection message rather than as
// "something went wrong talking to Dawarich", which points at the instance.
repo.atlasSuggestions.mockRejectedValue(new DawarichOfflineError())
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')),
)
// Still unasked, so the button that asks is the one on screen.
expect(screen.getByRole('button', { name: 'Look for countries' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-023: the other tab asks the other question, and each keeps its own answer', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Countries/ }))
// Switching tabs asks nothing on its own: a country reading costs an
// upstream request, and nobody asked for it by moving their eyes.
expect(repo.atlasSuggestions).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Wishlist/ }))
// The scan is still there, and was not repeated for the return trip.
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASDLG-024: "Check again" re-runs the scan and is inert while it runs', async () => {
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
const second = deferred<typeof SCAN>()
repo.bucketScan.mockReturnValueOnce(second.promise)
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
// A second press while the first is in flight would run the whole scan twice:
// one upstream request per wish, for the same answer.
expect(screen.getByRole('button', { name: 'Check again' })).toBeDisabled()
await act(async () => {
second.resolve(SCAN)
})
await waitFor(() => expect(screen.getByRole('button', { name: 'Check again' })).toBeEnabled())
expect(repo.bucketScan).toHaveBeenCalledTimes(2)
})
it('FE-DAWARICH-ATLASDLG-025: "Check again" on the countries tab re-reads the countries, not the wishes', async () => {
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
await waitFor(() => expect(repo.atlasSuggestions).toHaveBeenCalledTimes(2))
expect(repo.bucketScan).not.toHaveBeenCalled()
// A year back from the moment it was asked, both times: the window is the
// same question asked again, not a widening one.
const calls = repo.atlasSuggestions.mock.calls as Array<[string, string]>
for (const [from, to] of calls) {
expect(Date.parse(to) - Date.parse(from)).toBe(365 * 24 * 60 * 60 * 1000)
}
expect(Date.parse(calls[1][0])).toBeGreaterThanOrEqual(Date.parse(calls[0][0]))
})
it('FE-DAWARICH-ATLASDLG-026: on a phone it is a sheet, and the count and the confirm live in its own footer', async () => {
phone.value = true
const onClose = vi.fn()
open({ onClose })
expect(screen.getByRole('dialog', { name: 'Dawarich' })).toBeInTheDocument()
// The desktop modal puts the title in a heading: its absence is the tell
// that the right shell rendered.
expect(screen.queryByRole('heading', { name: /Dawarich/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
expect(screen.getByText('1 selected')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalled()
})
it('FE-DAWARICH-ATLASDLG-027: a country that was just added stops being offered, and the Atlas is told', async () => {
const onChanged = vi.fn()
open({ initialTab: 'countries', onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
// Marked in place rather than re-read: the reading costs an upstream request
// and the only thing that changed is what the reader just pressed. The row
// leaving the list is what says the write landed.
await waitFor(() =>
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
)
expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull()
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1)
// The Atlas keeps its own map and its own counts; without this the country
// it was just told about only appears there after a reload.
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASDLG-028: a request that died without a reason at all still gets a sentence', async () => {
// An aborted request rejects with nothing: not an Error, not a response.
// Reaching into `.response.data` on that must not be the thing that takes
// the dialog down, because a crash here loses the scan the reader is
// looking at as well as the message they needed.
repo.bucketScan.mockRejectedValue(undefined)
open()
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
})
it('FE-DAWARICH-ATLASDLG-029: a country can be unticked and put back, and only the ticked ones are written', async () => {
// Both offers start ticked, which is the useful default but not the answer
// every time: a border crossed on the way to somewhere else is a country
// Dawarich saw and the reader does not want in their Atlas. Taking one out
// has to actually take it out of the write, and changing one's mind has to
// put it back, because the Atlas carries tombstones and an unwanted country
// added here is removed by hand afterwards.
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
open({ initialTab: 'countries' })
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Belgium' })).toBeInTheDocument())
expect(screen.getByRole('button', { name: /Add 2 countries/ })).toBeEnabled()
fireEvent.click(screen.getByRole('checkbox', { name: 'Belgium' }))
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
// Out and back in again: the tick is a toggle, not a one-way door.
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
expect(screen.getByRole('button', { name: /Add 0 countries/ })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
// Belgium was never sent, so nothing has to be undone in the Atlas after.
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR']))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
// And it is still on offer, still unticked, for the trip it does belong to.
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
})
})
@@ -0,0 +1,578 @@
import React, { useState } from 'react'
import { Building2, Check, CalendarDays, Globe2, Hourglass, Info, Loader2, MapPin, RefreshCw, Star } from 'lucide-react'
import type { DawarichAtlasSuggestions, DawarichBucketScan } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { Skeleton } from '../shared/Skeleton'
import { SlidingTabs } from '../shared/SlidingTabs'
import Modal from '../shared/Modal'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
import { useIsPhone } from '../../mobile/useIsPhone'
import DawarichIcon from '../shared/DawarichIcon'
import DawarichBadge from './DawarichBadge'
import { dawarichApi } from '../../api/dawarich'
import { dawarichRepo, DawarichOfflineError } from '../../repo/dawarichRepo'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { formatDuration } from './dawarichSuggestionModel'
import { cityLine, countryLabel, countryWindow, formatDistance, newCountries, orderedMatches } from './dawarichAtlasModel'
type Tab = 'wishes' | 'countries'
/**
* Everything Dawarich can answer about the Atlas, in a room of its own.
*
* It used to be a card wedged under the wishlist, which made two different
* questions — "which wishes did I actually reach?" and "which countries was I
* in?" — look like one paragraph of small print. Both are offers that need
* reading before they are accepted, and reading needs space, so they get a
* dialog with a tab each, one selection model and one confirm button.
*
* Nothing here runs on its own. The Atlas is curated — it carries tombstones
* precisely so a country somebody removed stays removed — and a background job
* that ticked countries off would be fighting its owner.
*/
export default function DawarichAtlasDialog({
isOpen,
initialTab = 'wishes',
onClose,
onChanged,
}: {
isOpen: boolean
/** Which question was asked to open it — the panel has a button per tab. */
initialTab?: Tab
onClose: () => void
/** Called after something was written, so the Atlas can re-read itself. */
onChanged?: () => void
}): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const phone = useIsPhone()
const [tab, setTab] = useState<Tab>(initialTab)
const [scan, setScan] = useState<DawarichBucketScan | null>(null)
const [scanning, setScanning] = useState(false)
const [picked, setPicked] = useState<Set<number>>(new Set())
const [countries, setCountries] = useState<DawarichAtlasSuggestions | null>(null)
const [loadingCountries, setLoadingCountries] = useState(false)
const [pickedCountries, setPickedCountries] = useState<Set<string>>(new Set())
// One write at a time: the button stays on screen while the request is in
// flight, and a second press would send the same confirmation twice.
const [writing, setWriting] = useState(false)
const matches = orderedMatches(scan?.matches ?? [])
const openMatches = matches.filter(match => !match.alreadyVisited)
const offered = newCountries(countries?.countries ?? [])
const runScan = async (): Promise<void> => {
setScanning(true)
try {
const result = await dawarichRepo.bucketScan()
setScan(result)
// Everything with a match and not already ticked starts selected: the
// common case is "yes, all of those", and unticking the odd one is less
// work than ticking twenty.
setPicked(new Set(result.matches.filter(m => m.match && !m.alreadyVisited).map(m => m.itemId)))
} catch (err) {
toast.error(failureText(err, t))
} finally {
setScanning(false)
}
}
const loadCountries = async (): Promise<void> => {
setLoadingCountries(true)
try {
const { from, to } = countryWindow(new Date())
const result = await dawarichRepo.atlasSuggestions(from, to)
setCountries(result)
setPickedCountries(new Set(newCountries(result.countries).map(c => c.countryCode)))
} catch (err) {
toast.error(failureText(err, t))
} finally {
setLoadingCountries(false)
}
}
const confirmWishes = async (): Promise<void> => {
if (picked.size === 0 || writing) return
// Writes are online-only: the reads are gated in the repo, and firing a
// confirmation into a dead connection would hang to a timeout and then
// report a server error for something the server never saw.
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return
}
setWriting(true)
try {
// Ticked off on the day each wish was reached, not on today. The dialog is
// holding that date for every row it is about to write, so it says it;
// grouping by date keeps that to one request per day rather than per wish.
const byDate = new Map<string, number[]>()
for (const m of matches) {
if (!picked.has(m.itemId) || !m.match) continue
const bucket = byDate.get(m.match.at)
if (bucket) bucket.push(m.itemId)
else byDate.set(m.match.at, [m.itemId])
}
const results = await Promise.all(
[...byDate].map(([at, ids]) => dawarichApi.confirmBucketVisits(ids, at)),
)
const result = { updated: results.reduce((sum, r) => sum + r.updated, 0) }
toast.success(t('dawarich.bucket.confirmed', { count: result.updated }))
// Marked in place rather than rescanned: the scan costs one upstream
// request per wish, and the reader already knows what they just ticked.
setScan(prev =>
prev === null
? prev
: { ...prev, matches: prev.matches.map(m => (picked.has(m.itemId) ? { ...m, alreadyVisited: true } : m)) },
)
setPicked(new Set())
onChanged?.()
} catch (err) {
toast.error(failureText(err, t))
} finally {
setWriting(false)
}
}
const confirmCountries = async (): Promise<void> => {
if (pickedCountries.size === 0 || writing) return
if (isEffectivelyOffline()) {
toast.error(t('dawarich.error.offline'))
return
}
setWriting(true)
try {
const result = await dawarichApi.acceptAtlasCountries([...pickedCountries])
toast.success(t('dawarich.atlas.accepted', { count: result.marked }))
setCountries(prev =>
prev === null
? prev
: {
...prev,
countries: prev.countries.map(c =>
pickedCountries.has(c.countryCode) ? { ...c, alreadyVisited: true } : c,
),
},
)
setPickedCountries(new Set())
onChanged?.()
} catch (err) {
toast.error(failureText(err, t))
} finally {
setWriting(false)
}
}
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
const next = new Set(set)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
const busy = tab === 'wishes' ? scanning : loadingCountries
const loaded = tab === 'wishes' ? scan !== null : countries !== null
const selected = tab === 'wishes' ? picked.size : pickedCountries.size
// Nothing was found, so there is nothing to confirm — the empty state says so
// in a sentence, and a greyed-out "Add 0 countries" underneath it only asks to
// be understood.
const anything = tab === 'wishes' ? matches.length > 0 : offered.length > 0
// The same two questions in both shells — only the frame differs.
const body = (
<div className="space-y-4">
<SlidingTabs<Tab>
tabs={[
{ id: 'wishes', label: t('dawarich.atlas.tab.wishes'), icon: Star, count: openMatches.length || undefined },
{ id: 'countries', label: t('dawarich.atlas.tab.countries'), icon: Globe2, count: offered.length || undefined },
]}
activeTab={tab}
onChange={setTab}
size="sm"
fullWidth
/>
{tab === 'wishes' ? (
<Section
icon={Star}
title={t('dawarich.bucket.title')}
description={t('dawarich.bucket.description')}
action={t('dawarich.bucket.scan')}
busyLabel={t('dawarich.bucket.scanning')}
busy={scanning}
loaded={scan !== null}
onRun={() => { void runScan() }}
empty={matches.length === 0}
emptyLabel={t('dawarich.bucket.noMatches')}
notes={[
scan && scan.skippedWithoutCoordinates > 0
? t('dawarich.bucket.skipped', { count: scan.skippedWithoutCoordinates })
: null,
scan?.truncated ? t('dawarich.bucket.truncated') : null,
t('dawarich.bucket.rule', {
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
}),
]}
>
{matches.map(match => (
<Row
key={match.itemId}
name={match.name}
checked={picked.has(match.itemId)}
disabled={match.alreadyVisited}
onToggle={() => setPicked(prev => toggle(prev, match.itemId))}
leading={
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary">
<Star className="w-4 h-4 text-accent-on" />
</span>
}
>
{match.alreadyVisited ? (
<DawarichBadge icon={Check} tone="success" size="sm">
{t('dawarich.bucket.alreadyVisited')}
</DawarichBadge>
) : (
<>
<DawarichBadge icon={MapPin} size="sm">
{formatDistance(match.match!.distanceMeters, t)}
</DawarichBadge>
<DawarichBadge icon={Hourglass} size="sm">
{formatDuration(match.match!.minutes, t)}
</DawarichBadge>
<DawarichBadge icon={CalendarDays} size="sm">
{new Date(match.match!.at).toLocaleDateString(locale, {
weekday: 'short',
day: 'numeric',
month: 'short',
})}
</DawarichBadge>
</>
)}
</Row>
))}
</Section>
) : (
<Section
icon={Globe2}
title={t('dawarich.atlas.title')}
description={t('dawarich.atlas.description')}
action={t('dawarich.atlas.load')}
busyLabel={t('dawarich.atlas.loading')}
busy={loadingCountries}
loaded={countries !== null}
onRun={() => { void loadCountries() }}
empty={offered.length === 0}
emptyLabel={t('dawarich.atlas.empty')}
notes={[
countries && countries.unresolved.length > 0
? t('dawarich.atlas.unresolved', { names: countries.unresolved.join(', ') })
: null,
countries ? t('dawarich.atlas.window') : null,
]}
>
{offered.map(country => (
<Row
key={country.countryCode}
name={countryLabel(country.countryCode, country.sourceName, locale)}
checked={pickedCountries.has(country.countryCode)}
onToggle={() => setPickedCountries(prev => toggle(prev, country.countryCode))}
leading={
// The Atlas page draws its countries with the same flags, and a
// list of country names without them looks like a spreadsheet.
<img
src={`https://flagcdn.com/w40/${country.countryCode.toLowerCase()}.png`}
alt=""
className="h-6 w-8 flex-shrink-0 rounded object-cover"
/>
}
>
{country.cities.length > 0 && (
<>
<DawarichBadge icon={Building2} size="sm">
{country.cities.length === 1
? t('dawarich.atlas.citiesOne')
: t('dawarich.atlas.cities', { count: country.cities.length })}
</DawarichBadge>
<span className="min-w-0 truncate text-caption text-content-muted">
{cityLine(country.cities)}
</span>
</>
)}
</Row>
))}
</Section>
)}
</div>
)
// Rendered or not, rather than `hidden`: the attribute loses to the display
// utility on the class list, so the button stayed on screen greyed out.
const confirmButton = !loaded || !anything ? null : (
<button
type="button"
onClick={() => { void (tab === 'wishes' ? confirmWishes() : confirmCountries()) }}
disabled={selected === 0 || writing}
className={
phone
? 'inline-flex h-[38px] items-center gap-2 rounded-full bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-40'
: 'inline-flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-40'
}
>
<Check className="w-3.5 h-3.5" />
{tab === 'wishes'
? t('dawarich.bucket.confirm', { count: selected })
: t('dawarich.atlas.accept', { count: selected })}
</button>
)
const againButton = loaded ? (
<button
type="button"
onClick={() => { void (tab === 'wishes' ? runScan() : loadCountries()) }}
disabled={busy}
className={
phone
? 'inline-flex h-[38px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[0.8125rem] font-bold text-m-muted disabled:opacity-50'
: 'inline-flex items-center gap-2 rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary hover:bg-surface-hover disabled:opacity-50'
}
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
{t('dawarich.again')}
</button>
) : null
// The phone shell has its own sheet, its own rounding and its own pill
// buttons, and every other panel on it uses them.
if (phone) {
return (
<MSheet open={isOpen} onClose={onClose} variant="bottom" material="glass" ariaLabel={t('dawarich.title')}>
<div className="flex max-h-[88dvh] min-h-0 flex-col">
<FormSheetHeader
leading={
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
<DawarichIcon size={40} />
</span>
}
title={t('dawarich.title')}
subtitle={t('dawarich.atlas.dialogSubtitle')}
onClose={onClose}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{body}</div>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<span className="min-w-0 flex-1 truncate font-geist text-[0.71875rem] text-m-muted">
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
</span>
{againButton}
{confirmButton}
</div>
</div>
</MSheet>
)
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="xl"
title={
<span className="flex items-center gap-2.5">
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
square badge and a hard corner beside a rounded dialog reads as a
pasted-in image. */}
<span className="flex-shrink-0 overflow-hidden rounded-lg">
<DawarichIcon size={26} />
</span>
<span className="min-w-0">
<span className="block truncate text-subtitle font-semibold text-content">{t('dawarich.title')}</span>
<span className="block truncate text-caption font-normal text-content-muted">
{t('dawarich.atlas.dialogSubtitle')}
</span>
</span>
</span>
}
footer={
<div className="flex w-full items-center justify-between gap-3">
<span className="text-caption text-content-muted">
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
</span>
<div className="flex items-center gap-2">
{againButton}
{confirmButton}
</div>
</div>
}
>
{body}
</Modal>
)
}
/**
* One half of the dialog: the pitch before anything has been asked, the wait
* while it is being asked, and the answer.
*
* Both halves behave identically, which is the point — the two questions are
* different, the shape of answering them is not.
*/
function Section({
icon: Icon,
title,
description,
action,
busyLabel,
busy,
loaded,
onRun,
empty,
emptyLabel,
notes,
children,
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
action: string
busyLabel: string
busy: boolean
loaded: boolean
onRun: () => void
empty: boolean
emptyLabel: string
notes: (string | null)[]
children: React.ReactNode
}): React.ReactElement {
if (busy) {
return (
<div className="space-y-2" aria-busy>
<p className="text-caption text-content-muted">{busyLabel}</p>
{[0, 1, 2].map(i => (
<div key={i} className="flex items-center gap-3 rounded-xl border border-edge p-3">
<Skeleton width={32} height={32} radius={8} />
<div className="flex-1 space-y-1.5">
<Skeleton width="45%" height={13} radius={4} />
<Skeleton width="70%" height={11} radius={4} />
</div>
</div>
))}
</div>
)
}
if (!loaded) {
return (
<div className="flex flex-col items-center rounded-xl border border-dashed border-edge px-6 py-8 text-center">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-surface-secondary">
<Icon className="w-5 h-5 text-content-secondary" />
</span>
<p className="text-body font-semibold text-content">{title}</p>
<p className="mt-1.5 max-w-md text-caption text-content-muted">{description}</p>
<button
type="button"
onClick={onRun}
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover"
>
<RefreshCw className="w-3.5 h-3.5" />
{action}
</button>
</div>
)
}
return (
<div className="space-y-3">
{empty ? (
<div className="rounded-xl border border-dashed border-edge px-6 py-8 text-center text-caption text-content-muted">
{emptyLabel}
</div>
) : (
<div className="space-y-1.5">{children}</div>
)}
{notes.filter(Boolean).length > 0 && (
<div className="space-y-1 border-t border-edge-faint pt-2.5">
{notes.filter(Boolean).map(note => (
<p key={note} className="flex items-start gap-1.5 text-caption text-content-faint">
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
{note}
</p>
))}
</div>
)}
</div>
)
}
/**
* A selectable answer: what it is on the left, why it is being offered
* underneath, and a tick that says whether it will be applied.
*
* The whole row is the control — a 16px checkbox is a hard target on a phone,
* and there is nothing else in the row to click.
*/
function Row({
name,
checked,
disabled = false,
onToggle,
leading,
children,
}: {
name: string
checked: boolean
disabled?: boolean
onToggle: () => void
leading: React.ReactNode
children: React.ReactNode
}): React.ReactElement {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={name}
disabled={disabled}
onClick={onToggle}
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-left transition-colors ${
checked ? 'border-accent bg-accent-subtle' : 'border-edge hover:bg-surface-hover'
} ${disabled ? 'opacity-60' : ''}`}
>
<span
aria-hidden
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded ${
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
}`}
>
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
</span>
{leading}
<span className="min-w-0 flex-1">
<span className="block truncate text-body font-medium text-content">{name}</span>
<span className="mt-1 flex flex-wrap items-center gap-1">{children}</span>
</span>
</button>
)
}
/**
* What to tell the reader when a question could not be asked.
*
* Being offline is not an error the server reported — nothing was sent — and
* "something went wrong" for a plane journey is the kind of message that sends
* somebody looking for a bug in their Dawarich instance.
*/
function failureText(err: unknown, t: (key: string) => string): string {
if (err instanceof DawarichOfflineError) return t('dawarich.error.offline')
return errorText(err) ?? t('dawarich.error.unknown')
}
/** The server's `{ error }` envelope, when there is one. */
function errorText(err: unknown): string | null {
const data = (err as { response?: { data?: { error?: unknown } } })?.response?.data
return typeof data?.error === 'string' ? data.error : null
}
@@ -0,0 +1,174 @@
// FE-DAWARICH-ATLASPANEL-001 to FE-DAWARICH-ATLASPANEL-007
/**
* Dawarich's own panel on the Atlas, beside the stats.
*
* The panel itself holds almost no logic, and the little it does hold is the
* kind that is invisible until it is wrong:
*
* - **It has to disappear completely when the addon is off.** Not empty, not a
* blank pane of glass in the row beside the stats: absent, so an Atlas
* without Dawarich looks exactly as it did before the integration landed.
* - **It has to ask the question that was pressed.** Two tiles, one dialog, one
* `initialTab` between them. Wiring both tiles to the same tab is a bug that
* looks like a working panel.
* - **It mounts the dialog on open and throws it away on close**, deliberately,
* so a second visit starts from a fresh question rather than from a scan
* somebody ran an hour ago.
* - **It is made of the same glass as the Atlas panel next to it.** The tint
* and the cursor glare are hand-written rgba rather than tokens (a solid
* surface token would punch an opaque hole in a panel floating over a blurred
* map), so nothing but a test stops the two panels drifting apart in the
* dark theme.
*
* The dialog is pinned by its own file and stands in for itself here, so the
* panel's decisions are observable without a scan and without mocking Dawarich.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent } from '@testing-library/react'
import { render, screen } from '../../../tests/helpers/render'
import { useAddonStore } from '../../store/addonStore'
import DawarichAtlasSidePanel from './DawarichAtlasSidePanel'
vi.mock('./DawarichAtlasDialog', () => ({
default: ({
initialTab,
onClose,
onChanged,
}: {
initialTab: string
onClose: () => void
onChanged?: () => void
}) => (
<div data-testid="atlas-dialog">
<span>asked: {initialTab}</span>
<button type="button" onClick={onClose}>
dismiss
</button>
<button type="button" onClick={() => onChanged?.()}>
wrote something
</button>
</div>
),
}))
const ADDON = { id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'route', enabled: true }
/** The glass the Atlas hands down, reduced to the one property jsdom can answer for. */
const GLASS: React.CSSProperties = { borderRadius: 20 }
/** Inline rgba comes back from jsdom respaced; nothing here cares about the spaces. */
const rgba = (el: HTMLElement): string => el.style.background.replace(/\s/g, '')
function mount(props: Partial<React.ComponentProps<typeof DawarichAtlasSidePanel>> = {}) {
return render(<DawarichAtlasSidePanel style={GLASS} dark {...props} />)
}
beforeEach(() => {
useAddonStore.setState({ addons: [ADDON], loaded: true })
})
afterEach(() => {
useAddonStore.setState({ addons: [], loaded: false })
})
describe('DawarichAtlasSidePanel', () => {
it('FE-DAWARICH-ATLASPANEL-001: an Atlas without the addon gets no panel at all', () => {
useAddonStore.setState({ addons: [{ ...ADDON, enabled: false }] })
const { container } = mount()
// Nothing rendered, rather than an empty pane of glass in the row: the
// panel is stretched to the height of the stats beside it, so an empty one
// would be a visible hole.
expect(container).toBeEmptyDOMElement()
expect(screen.queryByRole('button', { name: 'Wishlist' })).toBeNull()
})
it('FE-DAWARICH-ATLASPANEL-002: with the addon on it offers two questions and asks neither', () => {
const { container } = mount()
expect(screen.getByText('Dawarich')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Wishlist' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Countries' })).toBeInTheDocument()
// A scan costs one upstream request per wish. Nothing is asked until asked.
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
// And it wears the glass it was handed rather than one of its own.
expect(container.firstElementChild).toHaveStyle({ borderRadius: '20px' })
})
it('FE-DAWARICH-ATLASPANEL-003: the wishlist tile opens the dialog on the wishlist, and closing puts it away', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Wishlist' }))
expect(screen.getByText('asked: wishes')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'dismiss' }))
// Unmounted, not hidden: the next visit starts from a clean question rather
// than from an answer that is an hour old.
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
})
it('FE-DAWARICH-ATLASPANEL-004: the countries tile opens the same dialog on the other question', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
expect(screen.getByText('asked: countries')).toBeInTheDocument()
expect(screen.queryByText('asked: wishes')).toBeNull()
})
it('FE-DAWARICH-ATLASPANEL-005: what the dialog wrote is reported up to the Atlas', () => {
// The Atlas keeps its own counts and its own map. Without this the confirmed
// countries only appear after a reload.
const onChanged = vi.fn()
mount({ onChanged })
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
fireEvent.click(screen.getByRole('button', { name: 'wrote something' }))
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-ATLASPANEL-006: the tiles are a tint of the light the panel is made of, per theme', () => {
const { rerender } = render(<DawarichAtlasSidePanel style={GLASS} dark={false} />)
const wishes = screen.getByRole('button', { name: 'Wishlist' })
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
fireEvent.mouseEnter(wishes)
expect(rgba(wishes)).toBe('rgba(0,0,0,0.09)')
fireEvent.mouseLeave(wishes)
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
rerender(<DawarichAtlasSidePanel style={GLASS} dark />)
// Inverted on the dark map, not merely darkened: the tile is light added to
// the glass, and a black tint on a dark blurred map is an invisible button.
expect(rgba(screen.getByRole('button', { name: 'Wishlist' }))).toBe('rgba(255,255,255,0.07)')
})
it('FE-DAWARICH-ATLASPANEL-007: the glass glare follows the cursor and goes out behind it', () => {
const { container } = mount()
const panel = container.firstElementChild as HTMLElement
const [glare, borderGlare] = Array.from(panel.children) as HTMLElement[]
expect(glare.style.opacity).toBe('0')
fireEvent.mouseMove(panel, { clientX: 140, clientY: 60 })
// The pool of light has to sit under the pointer, not merely exist: the
// panel's box measures as the origin here, so the cursor's own coordinates
// are what should end up in the gradient. An effect that lit up in a fixed
// spot would pass an opacity-only assertion and look broken on screen.
expect(glare.style.background).toContain('at 140px 60px')
expect(glare.style.opacity).toBe('1')
expect(borderGlare.style.opacity).toBe('1')
fireEvent.mouseLeave(panel)
expect(glare.style.opacity).toBe('0')
expect(borderGlare.style.opacity).toBe('0')
})
})
@@ -0,0 +1,133 @@
import React, { useState } from 'react'
import { Globe2, Star } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useAddonStore } from '../../store/addonStore'
import { Tooltip } from '../shared/Tooltip'
import { useGlassGlare } from '../Atlas/useGlassGlare'
import DawarichIcon from '../shared/DawarichIcon'
import DawarichAtlasDialog from './DawarichAtlasDialog'
type Question = 'wishes' | 'countries'
/**
* Dawarich's own panel on the Atlas, beside the stats.
*
* The Atlas is a map with a single glass panel floating over it, and everything
* it offers lives in that panel. Dawarich answers two questions about the same
* map — which wishes were actually reached, and which countries the recordings
* say you were in — but they are *its* answers, not the Atlas's own numbers, and
* squeezed under the wishlist they read as fine print nobody asked for.
*
* So it gets a panel of its own, in the same glass and stretched to the same
* height, and the two questions are two tiles built like the stat blocks beside
* them: a mark, then a small capitalised label. The lists they produce open in a
* dialog — they are offers to read and confirm, and a bar over a world map is no
* place to read anything.
*
* Renders nothing when the addon is off, so an Atlas without Dawarich looks
* exactly as it did before.
*/
export default function DawarichAtlasSidePanel({
style,
dark,
onChanged,
}: {
/** The glass the Atlas panels are made of, handed down so the two cannot drift. */
style: React.CSSProperties
dark: boolean
/** Called after something was written, so the Atlas can re-read itself. */
onChanged?: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const addonEnabled = useAddonStore(state => state.isEnabled)
const [asked, setAsked] = useState<Question | null>(null)
const glare = useGlassGlare(dark)
if (!addonEnabled('dawarich')) return null
// theme-lint-disable — the panel floats on a blurred map: a solid surface
// token would punch an opaque hole in the glass, so the tiles are a tint of
// the same light the panel is made of.
const tile = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)'
const tileHover = dark ? 'rgba(255,255,255,0.13)' : 'rgba(0,0,0,0.09)'
const question = (id: Question, Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>, label: string) => (
<button
type="button"
onClick={() => setAsked(id)}
className="group flex flex-col items-center justify-center text-content-secondary hover:text-content"
style={{
background: tile,
border: 'none',
borderRadius: 14,
cursor: 'pointer',
fontFamily: 'inherit',
gap: 6,
minWidth: 74,
padding: '10px 12px',
transition: 'background 0.15s ease, color 0.15s ease',
}}
onMouseEnter={e => { e.currentTarget.style.background = tileHover }}
onMouseLeave={e => { e.currentTarget.style.background = tile }}
>
<Icon size={17} strokeWidth={2} />
<span
className="font-bold uppercase tracking-wide whitespace-nowrap"
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
>
{label}
</span>
</button>
)
return (
<>
<div
ref={glare.panelRef}
onMouseMove={glare.onMouseMove}
onMouseLeave={glare.onMouseLeave}
className="relative hidden md:flex flex-col justify-center gap-3 overflow-hidden px-5 py-4"
style={style}
>
{/* Liquid glass glare, and the border glow that follows the cursor —
the same two layers the Atlas panel carries. */}
<div ref={glare.glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
<div ref={glare.borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
}} />
{/* The long explanation moved into the tooltip: on a panel this size it
was three lines of grey that said what the two tiles already say. */}
<Tooltip label={t('dawarich.atlas.trigger')} placement="top">
<span className="relative flex items-center justify-center gap-2.5">
<span className="flex-shrink-0 overflow-hidden rounded-[9px]">
<DawarichIcon size={30} />
</span>
<span
className="truncate font-black tracking-tight text-content"
style={{ fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))' }}
>
{t('dawarich.title')}
</span>
</span>
</Tooltip>
<div className="relative flex items-stretch gap-2">
{question('wishes', Star, t('dawarich.atlas.tab.wishes'))}
{question('countries', Globe2, t('dawarich.atlas.tab.countries'))}
</div>
</div>
{/* Mounted on open so every visit starts from a clean question rather than
from a scan somebody ran an hour ago. */}
{asked !== null && (
<DawarichAtlasDialog
isOpen
initialTab={asked}
onClose={() => setAsked(null)}
onChanged={onChanged}
/>
)}
</>
)
}
@@ -0,0 +1,172 @@
// FE-DAWARICH-BADGE-001 to FE-DAWARICH-BADGE-007
/**
* The one pill the whole Dawarich surface is drawn from, pinned prop by prop.
*
* It is nine lines of JSX, which is exactly why it is worth a test: the review
* list, the accept dialog and the Atlas dialog all render it, so a change here
* lands in three places at once and none of their tests would name it as the
* culprit.
*
* What is worth holding still, roughly in the order of what breaks worst:
*
* - **Icon-only means named.** `children === undefined` is the switch that
* turns a badge into a round dot of colour, and it is the same switch that
* adds `role="img"` and the accessible name. Lose the pairing and the low
* confidence flag, the "source gone" flag and the "source changed" flag all
* become unlabelled squares that a screen reader reads as nothing at all.
* - **`children === undefined`, not `!children`.** The day header counts its
* stays with the array length, so a falsy check would turn a legitimate
* count of zero into an unlabelled dot.
* - **The tone is the meaning.** Five tones, each a pair of theme tokens; a
* warning that quietly maps to the neutral pair still reads as a fact worth
* ignoring, which is the opposite of what it is there to say.
* - **The type scales with the reader's setting.** Both sizes go through
* `--fs-scale-caption`; a raw `fontSize: 11` would look identical in review
* and ignore the text-size preference entirely.
* - **A badge with something to add gets TREK's tooltip, one without gets
* none.** `title` wins over `label` as the hover text, so a pointer user can
* be told more than the accessible name says.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import DawarichBadge from './DawarichBadge'
type BadgeProps = React.ComponentProps<typeof DawarichBadge>
// The clock goes in before anything renders, the way Tooltip's own suite does
// it: swapping timers out from under a mounted tree leaves whatever it already
// scheduled on the real clock. `shouldAdvanceTime` keeps real time moving, so
// the locale promise the render helper's provider starts still settles.
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
/**
* Stands in for a lucide icon. All the badge does with one is hand it a
* className, so that string is the only thing worth asserting about it.
*/
function StubIcon({ className }: { className?: string }) {
return <svg data-testid="badge-icon" className={className} />
}
function badge(props: BadgeProps) {
const view = render(<DawarichBadge {...props} />)
return { ...view, el: view.container.firstElementChild as HTMLElement }
}
/**
* What the badge says on hover, or null when it has nothing to add and is
* therefore not wrapped in a tooltip at all. The tooltip opens on a 250 ms
* timer, so the clock has to be moved on by hand for either answer to be true
* rather than merely early.
*/
function hoverText(el: HTMLElement): string | null {
fireEvent.mouseEnter(el)
// One window covers both steps: the 250 ms open timer, and the effect that
// measures the trigger afterwards. Until that effect lands the portal is
// `visibility: hidden`, which is invisible to a role query as well as to a
// reader, so a half-flushed tooltip would read as no tooltip at all.
act(() => { vi.advanceTimersByTime(300) })
return screen.queryByRole('tooltip')?.textContent ?? null
}
describe('DawarichBadge', () => {
it('FE-DAWARICH-BADGE-001: the default pill is neutral, xs, and says nothing on hover', () => {
const { el } = badge({ children: '3 stays' })
expect(el).toHaveTextContent('3 stays')
expect(el).toHaveClass('h-[18px]', 'px-1.5', 'bg-surface-secondary', 'text-content-secondary')
// A pill that already reads "3 stays" needs no image role and no name of
// its own: both would make a screen reader announce the same thing twice.
expect(el).not.toHaveAttribute('role')
expect(el).not.toHaveAttribute('aria-label')
expect(el.style.fontSize).toBe('calc(10px * var(--fs-scale-caption, 1))')
expect(el.querySelector('[data-testid="badge-icon"]')).toBeNull()
// No title and no label, so there is nothing a tooltip could add.
expect(hoverText(el)).toBeNull()
})
it('FE-DAWARICH-BADGE-002: sm raises the box, the icon and the type together', () => {
const { el } = badge({ icon: StubIcon, size: 'sm', children: '2 h 25 min' })
expect(el).toHaveClass('h-[20px]', 'px-1.5')
expect(el.style.fontSize).toBe('calc(11px * var(--fs-scale-caption, 1))')
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-3 h-3 flex-shrink-0')
// The text truncates and the glyph does not: a long name must run out of
// room before it squeezes the icon out of shape.
expect(el.querySelector('.truncate')).toHaveTextContent('2 h 25 min')
})
it('FE-DAWARICH-BADGE-003: an icon-only pill is square, named, and offers its name on hover', () => {
const { el } = badge({ icon: StubIcon, tone: 'warning', label: 'Low confidence' })
expect(el).toHaveAttribute('role', 'img')
expect(el).toHaveAttribute('aria-label', 'Low confidence')
expect(el).toHaveClass('w-[18px]', 'justify-center', 'bg-warning-soft', 'text-warning')
// Side padding would make the circle an oval, and there is no text to pad
// away from in the first place.
expect(el).not.toHaveClass('px-1.5')
expect(el.querySelector('.truncate')).toBeNull()
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-2.5 h-2.5 flex-shrink-0')
// No title, so the accessible name doubles as the hover text and a pointer
// user is told exactly what a screen reader is told.
expect(hoverText(el)).toBe('Low confidence')
})
it('FE-DAWARICH-BADGE-004: the square grows with the size, so the dot stays a circle', () => {
const { el } = badge({ icon: StubIcon, size: 'sm', label: 'Source gone' })
expect(el).toHaveClass('h-[20px]', 'w-[20px]', 'justify-center')
expect(el).not.toHaveClass('w-[18px]')
})
it('FE-DAWARICH-BADGE-005: title is the hover text, label stays the name', () => {
const { el } = badge({
icon: StubIcon,
tone: 'warning',
label: 'Low confidence',
title: 'Dawarich is not sure this stay happened',
})
// The two are not interchangeable: the name is what the badge is, the title
// is what it has to add, and a screen reader would drown in the long one.
expect(el).toHaveAttribute('aria-label', 'Low confidence')
expect(hoverText(el)).toBe('Dawarich is not sure this stay happened')
})
it('FE-DAWARICH-BADGE-006: every tone keeps its own pair of theme tokens', () => {
const tones: Array<[NonNullable<BadgeProps['tone']>, string[]]> = [
['neutral', ['bg-surface-secondary', 'text-content-secondary']],
['quiet', ['bg-surface-secondary', 'text-content-faint']],
['accent', ['bg-accent-subtle', 'text-accent-on']],
['success', ['bg-success-soft', 'text-success']],
['warning', ['bg-warning-soft', 'text-warning']],
]
for (const [tone, classes] of tones) {
const { el, unmount } = badge({ tone, children: tone })
// Tokens rather than colour literals, or the badge stops following the
// user's scheme the moment they pick one.
expect(el).toHaveClass(...classes)
unmount()
}
})
it('FE-DAWARICH-BADGE-007: a zero is a child, not an absent one', () => {
// The day header counts its stays with the array length. Icon-only is
// `children === undefined` and not a falsy check for exactly this reason: a
// count of 0 that turned into an unlabelled dot would be a day that lost
// its number.
const { el } = badge({ tone: 'quiet', children: 0 })
expect(el).toHaveTextContent('0')
expect(el).not.toHaveAttribute('role')
expect(el).toHaveClass('px-1.5')
})
})
@@ -0,0 +1,71 @@
import React from 'react'
import { Tooltip } from '../shared/Tooltip'
/**
* One badge shape for everything Dawarich shows.
*
* Fixed height and a single radius, so a row of them lines up however many there
* are; the tone is the only thing that varies, and it carries the meaning rather
* than decorating it. `quiet` is for a fact worth stating and not worth looking
* at twice.
*
* It lives in its own file because the review list, the accept dialog and the
* Atlas dialog all use it, and three copies of a pill is how two of them end up
* a pixel apart.
*/
export default function DawarichBadge({
icon: Icon,
tone = 'neutral',
size = 'xs',
title,
label,
children,
}: {
icon?: React.ComponentType<{ className?: string }>
tone?: 'neutral' | 'quiet' | 'accent' | 'success' | 'warning'
/**
* `xs` matches the places rail, where the panel sits beside rows of that size;
* `sm` is for the dialogs, which are read at arm's length rather than scanned.
*/
size?: 'xs' | 'sm'
/** Hover text. Defaults to `label` when the badge is icon-only. */
title?: string
/**
* The accessible name for an icon-only badge. Passing it instead of children
* is what makes a flag a round dot of colour without turning a screen reader
* into someone staring at an unlabelled icon.
*/
label?: string
children?: React.ReactNode
}): React.ReactElement {
const tones: Record<string, string> = {
neutral: 'bg-surface-secondary text-content-secondary',
quiet: 'bg-surface-secondary text-content-faint',
accent: 'bg-accent-subtle text-accent-on',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
}
const metrics =
size === 'sm'
? { style: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }, box: 'h-[20px]', icon: 'w-3 h-3' }
: { style: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }, box: 'h-[18px]', icon: 'w-2.5 h-2.5' }
const iconOnly = children === undefined
const hint = title ?? label
const badge = (
<span
aria-label={iconOnly ? label : undefined}
role={iconOnly ? 'img' : undefined}
style={metrics.style}
className={`inline-flex ${metrics.box} max-w-full flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full font-semibold ${
iconOnly ? (size === 'sm' ? 'w-[20px] justify-center' : 'w-[18px] justify-center') : 'px-1.5'
} ${tones[tone]}`}
>
{Icon && <Icon className={`${metrics.icon} flex-shrink-0`} />}
{!iconOnly && <span className="truncate">{children}</span>}
</span>
)
// TREK's own tooltip rather than the browser's: the native one ignores the
// colour scheme, waits a second and a half, and cannot be read on a touch
// device at all. A badge with nothing to add gets none.
return hint ? <Tooltip label={hint} placement="top">{badge}</Tooltip> : badge
}
@@ -0,0 +1,640 @@
// FE-DAWARICH-PANEL-001 to FE-DAWARICH-PANEL-033
/**
* The review panel both shells share, pinned end to end.
*
* `useDawarichSuggestions` is mocked rather than the network: the hook is
* already covered on its own, and driving the panel through six `status`
* values, a busy row and a refused accept via MSW would mean six round trips to
* assert one branch each. What is NOT mocked is anything the panel renders
* itself, `DawarichAcceptDialog` included, because the wiring between a row's
* button and that dialog's confirm is exactly the part that would rot silently.
*
* What is worth pinning here, roughly in the order of what breaks worst:
*
* - **The panel renders nothing when there is nothing to review.** A connected,
* quiet integration is meant to be invisible; the moment that early return
* goes, every trip in the app grows a permanent empty card explaining that
* Dawarich found nothing.
* - **`accept` is the only path that writes.** The dialog closes and the host
* is notified only when the hook says the write landed, so a refused accept
* has to leave the dialog standing with the user's corrections still in it.
* - **The two accept buttons are conditional on there being somewhere to put
* the stay.** The planner has no journey list and the journal has no trip
* list; offering the missing one opens an empty picker.
* - **A shrinking place count means somebody deleted what an acceptance
* created**, which puts the stay back into review on the server. If that
* effect stops firing, the row stays hidden until a full reload.
* - **The phone shell paints from `--m-*`.** Both shells run this one
* component, so every palette fork inside it is real behaviour that no
* mobile-screen test covers.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act, within } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import type { DawarichSuggestion } from '@trek/shared'
import type { Place } from '../../types'
import type { DawarichSuggestionsState } from '../../hooks/useDawarichSuggestions'
import { useTripStore } from '../../store/tripStore'
import DawarichSuggestionsPanel from './DawarichSuggestionsPanel'
// The hook is the panel's whole world: status, rows, the busy row and the three
// mutations. Held in a `let` the factory reads lazily, so each test hands the
// panel a different world without re-importing the component.
let S: DawarichSuggestionsState
const seenFilter = vi.fn()
vi.mock('../../hooks/useDawarichSuggestions', () => ({
useDawarichSuggestions: (filter?: { tripId?: number }) => {
seenFilter(filter)
return S
},
}))
// Both the panel and the accept dialog read this module, and the relative
// specifier resolves to the same file from either, so one mock flips the whole
// tree between the desktop tokens and the phone's own palette.
let phone = false
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone }))
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
return {
id: over.id,
sourceVisitId: String(over.id),
tripId: over.tripId ?? null,
tripTitle: over.tripTitle ?? null,
name: over.name ?? 'Cafe Reichard',
lat: over.lat ?? 50.94,
lng: over.lng ?? 6.96,
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
durationMinutes: over.durationMinutes ?? 145,
localDate: over.localDate,
sourceStatus: over.sourceStatus ?? 'suggested',
confidence: over.confidence ?? null,
confidenceBand: over.confidenceBand ?? null,
state: over.state ?? 'new',
target: over.target ?? null,
acceptedPlaceId: over.acceptedPlaceId ?? null,
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
sourceChanged: over.sourceChanged ?? false,
sourceMissing: over.sourceMissing ?? false,
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
matchedBucketListName: over.matchedBucketListName ?? null,
countryCode: over.countryCode ?? null,
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
}
}
const accept = vi.fn<(id: number, body: unknown) => Promise<boolean>>()
const dismiss = vi.fn<(id: number) => Promise<void>>()
const restore = vi.fn<(id: number) => Promise<void>>()
const reload = vi.fn()
function hookState(over: Partial<DawarichSuggestionsState> = {}): DawarichSuggestionsState {
return {
suggestions: [],
status: 'ready',
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
busyId: null,
reload,
accept,
dismiss,
restore,
...over,
}
}
/** Only the one field the panel's place-count effect reads. */
function places(count: number): Place[] {
return Array.from({ length: count }, (_, i) => ({ id: i + 1 })) as unknown as Place[]
}
const TRIPS = [{ id: 5, label: 'Cologne' }]
const JOURNALS = [{ id: 9, label: 'Rhine notes' }]
function panel(props: Partial<React.ComponentProps<typeof DawarichSuggestionsPanel>> = {}) {
return render(<DawarichSuggestionsPanel {...props} />)
}
/** The collapsible header, which is also the only element carrying aria-expanded. */
function header(): HTMLElement {
return screen.getByRole('button', { name: /From Dawarich/ })
}
/** The row card holding a given stay name, for scoping a query to one row. */
function rowOf(container: HTMLElement, name: string): HTMLElement {
const row = within(container).getByText(name).closest('.items-stretch')
if (!row) throw new Error(`no row found for ${name}`)
return row as HTMLElement
}
beforeEach(() => {
phone = false
S = hookState()
seenFilter.mockReset()
accept.mockReset().mockResolvedValue(true)
dismiss.mockReset().mockResolvedValue(undefined)
restore.mockReset().mockResolvedValue(undefined)
reload.mockReset()
useTripStore.setState({ places: [] })
})
afterEach(() => {
useTripStore.setState({ places: [] })
})
describe('DawarichSuggestionsPanel: whether it shows up at all', () => {
it('FE-DAWARICH-PANEL-001: an addon nobody switched on renders nothing', () => {
S = hookState({ status: 'idle' })
const { container } = panel()
expect(container).toBeEmptyDOMElement()
})
it('FE-DAWARICH-PANEL-002: connected and quiet is invisible, not an empty card', () => {
// Read successfully, nothing pending and nothing in the history: the panel
// stays out of the rail rather than explaining that it found nothing.
S = hookState({ status: 'ready', suggestions: [] })
const { container } = panel()
expect(container).toBeEmptyDOMElement()
})
it('FE-DAWARICH-PANEL-003: it does show up while it is still reading, before it knows', () => {
S = hookState({ status: 'loading', suggestions: [] })
panel()
// Twice: once under the title, once in the opened body.
expect(screen.getAllByText('Reading Dawarich…')).toHaveLength(2)
expect(header()).toHaveAttribute('aria-expanded', 'true')
})
it('FE-DAWARICH-PANEL-004: a failed read is said in the danger colour, without the stale error under it', () => {
S = hookState({ status: 'unavailable', lastSyncError: 'Dawarich rejected the API key.' })
const { container } = panel()
const line = container.querySelector('p.text-danger')
expect(line).not.toBeNull()
expect(line).toHaveTextContent('Dawarich could not be read.')
// The sync error belongs above a list that did load. Beside "could not be
// read" it is the same news twice in two different wordings.
expect(screen.queryByText('Dawarich rejected the API key.')).toBeNull()
})
it('FE-DAWARICH-PANEL-005: not-connected and offline each get their own sentence', () => {
S = hookState({ status: 'disconnected' })
const { unmount } = panel()
expect(screen.getAllByText('Connect Dawarich in Settings to see your stays here.')).toHaveLength(2)
unmount()
S = hookState({ status: 'offline' })
panel()
expect(screen.getAllByText('The recorded route needs a connection')).toHaveLength(2)
})
it('FE-DAWARICH-PANEL-006: freshness reads as a relative time, and says so when there is none', () => {
S = hookState({
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
lastSyncAt: new Date(Date.now() - 120_000).toISOString(),
})
const { unmount } = panel()
// "checked 2 minutes ago" answers the question; a stamp makes the reader
// do the subtraction.
expect(header()).toHaveTextContent(/checked 2 minutes ago/)
unmount()
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })], lastSyncAt: null })
panel()
expect(header()).toHaveTextContent('Not checked yet')
})
it('FE-DAWARICH-PANEL-007: the trip filter is handed to the hook', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ tripId: 5 })
expect(seenFilter).toHaveBeenCalledWith({ tripId: 5 })
})
})
describe('DawarichSuggestionsPanel: the list', () => {
it('FE-DAWARICH-PANEL-008: stays are grouped by day, newest day first, each day counted', () => {
S = hookState({
suggestions: [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', startedAt: '2026-09-10T18:00:00+02:00' }),
stay({ id: 2, localDate: '2026-09-10', name: 'Cafe Reichard' }),
stay({ id: 3, localDate: '2026-09-11', name: 'Koelner Dom' }),
],
})
const { container } = panel({ trips: TRIPS })
const headings = Array.from(container.querySelectorAll('.uppercase')).map(node => node.textContent)
expect(headings).toEqual(['Fri, Sep 11', 'Thu, Sep 10'])
// The badge in the header repeats what the open list adds up to.
expect(header()).toHaveTextContent('3')
// The times, which are the only thing telling two stays on one day apart.
const row = rowOf(container, 'Cafe Reichard')
expect(row).toHaveTextContent('10:15')
expect(row).toHaveTextContent('12:40')
expect(row).toHaveTextContent('2 h 25 min')
})
it('FE-DAWARICH-PANEL-009: collapsed to start, and the header is the way back in', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
panel({ initiallyCollapsed: true, trips: TRIPS })
expect(header()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Museum Ludwig')).toBeNull()
fireEvent.click(header())
expect(header()).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-010: bare drops the card and its header, and cannot be collapsed shut', () => {
S = hookState({
suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })],
lastSyncAt: null,
})
// The phone journey opens this list inside a sheet that already carries a
// title, and `initiallyCollapsed` must not fold it away in there.
const { container } = panel({ bare: true, initiallyCollapsed: true, trips: TRIPS })
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
// Read the property rather than the attribute: what matters is that the
// card is gone, not how React chooses to serialise an empty className.
expect(container.querySelector('section')?.className).toBe('')
// The freshness line survives the missing header, as a plain paragraph.
expect(screen.getByText('Not checked yet')).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-011: a sync error rides above a list that did load', () => {
S = hookState({
status: 'ready',
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
lastSyncError: 'Dawarich asked TREK to slow down. Try again shortly.',
})
panel({ trips: TRIPS })
expect(screen.getByText('Dawarich asked TREK to slow down. Try again shortly.')).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-012: a row carries the flags worth interrupting for, and gone beats changed', () => {
S = hookState({
suggestions: [
stay({
id: 1,
localDate: '2026-09-10',
name: 'Museum Ludwig',
confidenceBand: 'low',
// Both set on purpose: a stay that vanished upstream may well have
// changed before it went, and saying both is two warnings about one
// row where only the fatal one matters.
sourceChanged: true,
sourceMissing: true,
}),
],
})
panel({ trips: TRIPS })
expect(screen.getByRole('img', { name: 'Unsure' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Gone from Dawarich' })).toBeInTheDocument()
expect(screen.queryByRole('img', { name: 'Changed in Dawarich' })).toBeNull()
expect(screen.getByText(/no longer exists in Dawarich/)).toBeInTheDocument()
expect(screen.queryByText(/has changed in Dawarich/)).toBeNull()
})
it('FE-DAWARICH-PANEL-013: a stay that only changed says exactly that, in full', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', sourceChanged: true })] })
panel({ trips: TRIPS })
expect(screen.getByRole('img', { name: 'Changed in Dawarich' })).toBeInTheDocument()
expect(screen.getByText(/has changed in Dawarich since you used it/)).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-014: the trip name shows only where rows can come from more than one trip', () => {
const rows = [stay({ id: 1, localDate: '2026-09-10', tripId: 5, tripTitle: 'Cologne' })]
S = hookState({ suggestions: rows })
const { unmount } = panel({ trips: TRIPS })
expect(screen.getByText('Cologne')).toBeInTheDocument()
unmount()
// Inside one trip's rail every row is from that trip, so the name is noise.
S = hookState({ suggestions: rows })
panel({ tripId: 5, trips: TRIPS })
expect(screen.queryByText('Cologne')).toBeNull()
})
it('FE-DAWARICH-PANEL-015: a stay with no trip behind it gets no empty badge', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', tripId: null, tripTitle: null })] })
const { container } = panel({ trips: TRIPS })
// Two badges on the row, the clock and the duration, and nothing else.
expect(rowOf(container, 'Cafe Reichard').querySelectorAll('.rounded-full.font-semibold')).toHaveLength(2)
})
})
describe('DawarichSuggestionsPanel: acting on a stay', () => {
it('FE-DAWARICH-PANEL-016: an action is offered only where there is somewhere to put the stay', () => {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
const { unmount } = panel({ trips: [], journals: [] })
// No trips and no journeys: only "not a place I visited" is still an answer.
expect(screen.queryByRole('button', { name: 'Add as a place' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Write a journal entry' })).toBeNull()
expect(screen.getByRole('button', { name: 'Not a place I visited' })).toBeInTheDocument()
unmount()
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS })
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Write a journal entry' })).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-017: dismissing hands the row id straight to the hook', () => {
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Not a place I visited' }))
expect(dismiss).toHaveBeenCalledWith(42)
})
it('FE-DAWARICH-PANEL-018: the row being written is the row that goes dead, and only that one', () => {
S = hookState({
suggestions: [
stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' }),
stay({ id: 43, localDate: '2026-09-11', name: 'Koelner Dom' }),
],
busyId: 42,
})
const { container } = panel({ trips: TRIPS })
expect(within(rowOf(container, 'Museum Ludwig')).getByRole('button', { name: 'Add as a place' })).toBeDisabled()
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Add as a place' })).toBeEnabled()
})
it('FE-DAWARICH-PANEL-019: accepting opens the review step and reports a write that landed', async () => {
const onAccepted = vi.fn()
const daysForTrip = vi.fn((tripId: number) => [{ id: 70 + tripId, label: 'Day 1', badge: 'Thu, Sep 10' }])
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
panel({ trips: TRIPS, journals: JOURNALS, daysForTrip, onAccepted })
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
// The dialog opens on the row that was pressed, for the target that was pressed.
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
expect(daysForTrip).toHaveBeenCalledWith(5)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
})
expect(accept).toHaveBeenCalledWith(42, expect.objectContaining({ target: 'place', tripId: 5 }))
// Only now: the host refreshes its own list off the back of a write that landed.
expect(onAccepted).toHaveBeenCalledWith(expect.objectContaining({ id: 42 }))
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
})
it('FE-DAWARICH-PANEL-020: a refused write leaves the dialog standing, with the correction still in it', async () => {
const onAccepted = vi.fn()
accept.mockResolvedValue(false)
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS, onAccepted })
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Corrected name' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
})
expect(accept).toHaveBeenCalledWith(
42,
expect.objectContaining({ target: 'journal', journalId: 9, name: 'Corrected name' }),
)
// Closing here would throw away what the user typed and leave them nothing
// to retry with.
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
expect(screen.getByLabelText('Name')).toHaveValue('Corrected name')
expect(onAccepted).not.toHaveBeenCalled()
})
it('FE-DAWARICH-PANEL-021: a host that wants no callback still gets its dialog closed', async () => {
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS })
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
})
expect(accept).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('heading', { name: 'Write a journal entry' })).toBeNull()
})
it('FE-DAWARICH-PANEL-022: cancelling writes nothing', () => {
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
panel({ trips: TRIPS, journals: JOURNALS })
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
expect(accept).not.toHaveBeenCalled()
})
})
describe('DawarichSuggestionsPanel: what was already dealt with', () => {
const handledRows = [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', state: 'accepted', target: 'place' }),
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
// Accepted before `target` was recorded: the wording falls back rather than
// printing a missing translation key at somebody.
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'accepted', target: null }),
]
it('FE-DAWARICH-PANEL-023: the history stays folded away until it is asked for', () => {
S = hookState({ suggestions: handledRows })
panel({ trips: TRIPS })
expect(screen.queryByText('Museum Ludwig')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
expect(screen.getByText('Added as a place')).toBeInTheDocument()
expect(screen.getByText('Dismissed')).toBeInTheDocument()
expect(screen.getByText('In the journal')).toBeInTheDocument()
// A history row carries its own date, which the open list keeps in the day
// heading it no longer sits under.
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Hide the ones already dealt with' }))
expect(screen.queryByText('Museum Ludwig')).toBeNull()
})
it('FE-DAWARICH-PANEL-024: only a dismissal can be put back, and it goes through the hook', () => {
S = hookState({ suggestions: handledRows })
const { container } = panel({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
// An accepted stay already produced a place or an entry; "put back" there
// would mean silently unpicking something the user wrote.
expect(within(rowOf(container, 'Museum Ludwig')).queryByRole('button', { name: 'Put back' })).toBeNull()
// And no history row offers the accept buttons a second time.
expect(within(rowOf(container, 'Koelner Dom')).queryByRole('button', { name: 'Add as a place' })).toBeNull()
fireEvent.click(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' }))
expect(restore).toHaveBeenCalledWith(2)
})
it('FE-DAWARICH-PANEL-025: a restore in flight disables the button that started it', () => {
S = hookState({ suggestions: handledRows, busyId: 2 })
const { container } = panel({ trips: TRIPS })
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' })).toBeDisabled()
})
it('FE-DAWARICH-PANEL-026: nothing left open says so instead of showing an empty day', () => {
S = hookState({ suggestions: [handledRows[0]] })
const { container } = panel({ trips: TRIPS })
expect(screen.getByText('Everything recorded here has been dealt with.')).toBeInTheDocument()
// And the count badge goes with the last open row rather than showing a zero.
expect(container.querySelector('.text-accent-text')).toBeNull()
})
})
describe('DawarichSuggestionsPanel: the deleted-place hint', () => {
it('FE-DAWARICH-PANEL-027: a shrinking place count reloads, a growing one does not', () => {
useTripStore.setState({ places: places(3) })
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ tripId: 5, trips: TRIPS })
// Mounting is not a shrink.
expect(reload).not.toHaveBeenCalled()
// Deleting the place an acceptance created is how somebody says "not that
// one" after the fact, and the server puts the stay back into review.
act(() => { useTripStore.setState({ places: places(2) }) })
expect(reload).toHaveBeenCalledTimes(1)
// A place appearing is usually this panel's own doing.
act(() => { useTripStore.setState({ places: places(6) }) })
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-PANEL-028: outside a trip the place count is not this panel\'s business', () => {
useTripStore.setState({ places: places(3) })
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
// The journal shell mounts this without a trip, and whatever trip happens to
// be loaded has nothing to do with the rows it shows.
panel({ trips: TRIPS })
act(() => { useTripStore.setState({ places: places(1) }) })
expect(reload).not.toHaveBeenCalled()
})
})
describe('DawarichSuggestionsPanel: the phone shell', () => {
it('FE-DAWARICH-PANEL-029: the phone paints from its own palette, header to action pill', () => {
phone = true
S = hookState({
suggestions: [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', sourceChanged: true }),
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
],
lastSyncAt: new Date(Date.now() - 60_000).toISOString(),
})
const { container } = panel({ trips: TRIPS, journals: JOURNALS })
fireEvent.click(screen.getByRole('button', { name: 'Show 1 already dealt with' }))
// A card in the desktop surface tokens glares inside the mobile shell, and
// neither token set exists outside its own shell, so every surface here has
// to come from the `--m-*` palette.
expect(container.querySelector('section')?.className).toContain('var(--m-ic)')
expect(header().className).toContain('var(--m-inner)')
expect(container.querySelector('.bg-m-card')).not.toBeNull()
expect(container.querySelector('.bg-m-act')).not.toBeNull()
expect(container.querySelector('.text-m-ink')).not.toBeNull()
expect(container.querySelectorAll('.text-m-muted').length).toBeGreaterThan(0)
expect(container.querySelector('.text-m-faint')).not.toBeNull()
// The actions keep their accessible names, which is what makes dropping the
// hover-only tooltip on a touch device cost nothing.
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
})
it('FE-DAWARICH-PANEL-030: bare on a phone, with nothing left open', () => {
phone = true
S = hookState({
suggestions: [stay({ id: 1, localDate: '2026-09-10', state: 'accepted', target: 'journal' })],
lastSyncAt: null,
})
const { container } = panel({ bare: true, trips: TRIPS })
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
expect(screen.getByText('Everything recorded here has been dealt with.').className).toContain('text-m-muted')
expect(screen.getByText('Not checked yet').className).toContain('text-m-muted')
expect(container.querySelector('section')?.className).toBe('')
})
it('FE-DAWARICH-PANEL-033: folded shut on a phone, the arrow that says so comes from the phone palette', () => {
phone = true
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
// The planner mounts this one collapsed, so on a phone the right-pointing
// arrow is the header's ordinary state and not an edge case. It is also the
// only part of the header an expanded phone never paints, which is exactly
// how a desktop token would survive in there unnoticed.
const { container } = panel({ initiallyCollapsed: true, trips: TRIPS })
expect(header()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Museum Ludwig')).toBeNull()
expect(container.querySelector('.lucide-chevron-right.text-m-faint')).not.toBeNull()
expect(container.querySelector('.lucide-chevron-down')).toBeNull()
expect(container.querySelector('.text-content-faint')).toBeNull()
})
})
describe('DawarichSuggestionsPanel: the desktop tooltip', () => {
it('FE-DAWARICH-PANEL-031: a row action explains itself on focus, where there is a pointer to do it with', () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
panel({ trips: TRIPS })
fireEvent.focus(screen.getByRole('button', { name: 'Not a place I visited' }))
act(() => { vi.advanceTimersByTime(300) })
// The icon is the whole button, so without this a square is a mystery to
// anybody who has not pressed one before. Reaching it by keyboard is the
// one route where there is no hover to discover it with.
expect(screen.getByRole('tooltip')).toHaveTextContent('Not a place I visited')
} finally {
vi.useRealTimers()
}
})
})
describe('DawarichSuggestionsPanel: open and handled in the same list', () => {
it('FE-DAWARICH-PANEL-032: the two halves are counted separately and only the open half gets days', () => {
S = hookState({
suggestions: [
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' }),
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'accepted', target: 'place' }),
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'dismissed' }),
],
})
const { container } = panel({ trips: TRIPS })
// Counting all three would send somebody looking for work that is done.
expect(container.querySelector('.bg-accent')).toHaveTextContent('1')
expect(screen.getByRole('button', { name: 'Show 2 already dealt with' })).toBeInTheDocument()
// And the day rail is built from the open half alone: a heading for a day
// whose only stay was dismissed is an empty day with a coloured dot on it.
expect(container.querySelectorAll('.uppercase')).toHaveLength(1)
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
})
})
@@ -0,0 +1,523 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, MapPin, BookOpen, Check, X, RotateCcw, AlertTriangle, Clock, Hourglass, CalendarDays, Briefcase, Gauge, History, Unplug } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
import { useTripStore } from '../../store/tripStore'
import { useIsPhone } from '../../mobile/useIsPhone'
import { relativeTime } from '../../utils/relativeTime'
import { Tooltip } from '../shared/Tooltip'
import DawarichIcon from '../shared/DawarichIcon'
import Badge from './DawarichBadge'
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
import { formatDayHeading, formatDuration, groupByDay, timeRange } from './dawarichSuggestionModel'
/**
* The panel sits inside the places rail, and it has to read at the same size as
* the rows below it — `text-body` (14px) and `text-caption` (12px) made every
* line of it a size larger than the trip's own places, which is the one thing a
* panel about somebody else's data must not be.
*
* These are the rail's own figures (PlacesSidebarRow uses exactly 13 / 11 / 10),
* kept as calc() against the per-tier multipliers so the appearance text-size
* control still reaches them — a bare number would not.
*/
const TYPE = {
name: { fontSize: 'calc(13px * var(--fs-scale-body, 1))' },
meta: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' },
micro: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' },
} as const
export interface DawarichSuggestionsPanelProps {
/** Only stays that fall inside this trip. */
tripId?: number
/** Trips the user may write a place into. */
trips?: AcceptTargetOption[]
daysForTrip?: (tripId: number) => AcceptTargetOption[]
/** Journeys the user may add an entry to. */
journals?: AcceptTargetOption[]
/** Called after something was written, so the host can refresh its own list. */
onAccepted?: (suggestion: DawarichSuggestion) => void
/** Start collapsed. The planner does; the journal does not. */
initiallyCollapsed?: boolean
/**
* Drop the panel's own card and header.
*
* For a sheet that already carries both — the phone's journey opens this list
* in one, and a second "From Dawarich" under the first is a stutter.
*/
bare?: boolean
}
/**
* The stays Dawarich recorded, waiting to be reviewed.
*
* One component for both shells. The phone and the desktop place it
* differently, but the rows are the same rows, and a second copy of them is a
* second thing to keep in step — the same reasoning as `TripRouteOverviewPanel`
* next door, and the same reason SonarCloud's duplication budget exists.
*
* **Grouped by day, and coloured like the map.** A day of travel produces four
* or five stays; repeating the full date on each of them buries the one thing
* that tells them apart, which is the time. The day's dot carries the colour its
* line is drawn in on the trip map, so "the purple day" means the same thing in
* both places.
*
* The panel renders **nothing at all** when there is nothing to review. An
* integration that is connected and quiet should be invisible, not a permanent
* empty card explaining that it found nothing.
*/
export default function DawarichSuggestionsPanel({
tripId,
trips = [],
daysForTrip,
journals = [],
onAccepted,
initiallyCollapsed = false,
bare = false,
}: DawarichSuggestionsPanelProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const S = useDawarichSuggestions({ tripId })
// The phone shell has its own palette (`--m-*`): a card in the desktop surface
// tokens glares next to it. Same component, the shell's own colours.
const phone = useIsPhone()
const [collapsed, setCollapsed] = useState(initiallyCollapsed)
const [showHandled, setShowHandled] = useState(false)
const [pending, setPending] = useState<{ suggestion: DawarichSuggestion; target: DawarichSuggestionTarget } | null>(null)
// Deleting the place an acceptance created is how somebody says "not that
// one" after the fact: the server puts the stay back into review, and a
// shrinking place count is what tells us to go and ask. Only a shrink — a
// place being added is usually this panel's own doing.
const placeCount = useTripStore(state => (tripId === undefined ? 0 : state.places.length))
const lastPlaceCount = useRef(placeCount)
const reload = S.reload
useEffect(() => {
const shrank = placeCount < lastPlaceCount.current
lastPlaceCount.current = placeCount
if (shrank) reload()
}, [placeCount, reload])
const open = useMemo(() => S.suggestions.filter(item => item.state === 'new'), [S.suggestions])
const handled = useMemo(() => S.suggestions.filter(item => item.state !== 'new'), [S.suggestions])
const openDays = useMemo(() => groupByDay(open), [open])
// Nothing to review and nothing to report: stay out of the way entirely.
if (S.status === 'idle') return null
if (S.status === 'ready' && open.length === 0 && handled.length === 0) return null
const confirm = async (body: DawarichAccept) => {
if (!pending) return
const ok = await S.accept(pending.suggestion.id, body)
if (ok) {
onAccepted?.(pending.suggestion)
setPending(null)
}
}
const rowProps = {
phone,
// Offered only where there is somewhere to put it: the planner has no
// journey list, and a button that opens an empty picker is worse than none.
allowJournal: journals.length > 0,
allowPlace: trips.length > 0,
// The trip name is noise when every row is from the same trip.
showTrip: tripId === undefined,
}
return (
<section
className={
bare
? ''
: `rounded-2xl overflow-hidden ${phone ? 'bg-[color:var(--m-ic)]' : 'border border-edge bg-surface-secondary'}`
}
>
{bare ? (
<p className={`px-1 pb-2 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
{statusLine(S, open.length, t, locale)}
</p>
) : (
<button
type="button"
onClick={() => setCollapsed(value => !value)}
aria-expanded={!collapsed}
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-left ${
phone ? 'active:bg-[color:var(--m-inner)]' : 'hover:bg-surface-hover'
}`}
>
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
square badge and a hard corner beside a rounded card reads as a
pasted-in image. */}
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
<DawarichIcon size={22} />
</span>
<span className="flex-1 min-w-0">
<span className={`block truncate font-semibold leading-tight ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
{t('dawarich.suggestions.title')}
</span>
<span className={`block truncate ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
{statusLine(S, open.length, t, locale)}
</span>
</span>
{open.length > 0 && (
<span
className={`flex-shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full font-bold ${
phone ? 'bg-m-act text-m-actfg' : 'bg-accent text-accent-text'
}`}
style={TYPE.micro}
>
{open.length}
</span>
)}
{collapsed
? <ChevronRight className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />
: <ChevronDown className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />}
</button>
)}
{(bare || !collapsed) && (
<div className="border-t border-edge-secondary">
{S.status !== 'ready' && (
<p className={`px-3.5 py-2.5 ${S.status === 'unavailable' ? 'text-danger' : 'text-content-secondary'}`} style={TYPE.meta}>
{statusLine(S, open.length, t, locale)}
</p>
)}
{S.lastSyncError && S.status === 'ready' && (
<p className="px-3.5 pt-2.5 text-danger" style={TYPE.meta}>{S.lastSyncError}</p>
)}
{open.length === 0 && S.status === 'ready' && (
<p className={`px-3.5 py-2.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>{t('dawarich.suggestions.allHandled')}</p>
)}
{openDays.map(day => (
<div key={day.date}>
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-1">
{/* The colour this day's line is drawn in on the map. */}
<span
aria-hidden="true"
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ background: day.color }}
/>
<span className={`font-bold uppercase tracking-wider whitespace-nowrap ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
{formatDayHeading(day.date, locale)}
</span>
<span className="flex-1 h-px bg-edge-faint" />
<Badge tone="quiet">{day.stays.length}</Badge>
</div>
{day.stays.map(suggestion => (
<SuggestionRow
key={suggestion.id}
suggestion={suggestion}
busy={S.busyId === suggestion.id}
{...rowProps}
onAccept={target => setPending({ suggestion, target })}
onDismiss={() => S.dismiss(suggestion.id)}
/>
))}
</div>
))}
{handled.length > 0 && (
<div className="border-t border-edge-faint">
<button
type="button"
onClick={() => setShowHandled(value => !value)}
className={`w-full px-3.5 py-2 text-left ${
phone ? 'text-m-muted active:bg-[color:var(--m-inner)]' : 'text-content-muted hover:bg-surface-hover'
}`}
style={TYPE.meta}
>
{showHandled
? t('dawarich.suggestions.hideHandled')
: t('dawarich.suggestions.showHandled', { count: handled.length })}
</button>
{showHandled &&
handled.map(suggestion => (
<SuggestionRow
key={suggestion.id}
suggestion={suggestion}
busy={S.busyId === suggestion.id}
showTrip={rowProps.showTrip}
showDate
onRestore={suggestion.state === 'dismissed' ? () => S.restore(suggestion.id) : undefined}
/>
))}
</div>
)}
</div>
)}
<DawarichAcceptDialog
suggestion={pending?.suggestion ?? null}
target={pending?.target ?? 'journal'}
trips={trips}
daysForTrip={daysForTrip}
journals={journals}
busy={S.busyId !== null}
onCancel={() => setPending(null)}
onConfirm={confirm}
/>
</section>
)
}
/**
* One stay: a name, when it was, and how to act on it.
*
* Two lines and no chip soup. The meta line carries only what varies — how long,
* and the two things worth interrupting for: a wish this fulfils, and a
* detection the source itself was unsure about. The "detected, unconfirmed"
* label that used to sit on nearly every row is gone: since Dawarich 1.12.0
* unconfirmed is the normal state of a visit, so saying it everywhere said
* nothing. Reviewing it here *is* the confirmation.
*
* The actions fade in on hover on a pointer device and stay visible on touch,
* where there is no hover to reveal them.
*/
function SuggestionRow({
suggestion,
busy,
allowJournal = false,
allowPlace = false,
showTrip = false,
showDate = false,
phone = false,
onAccept,
onDismiss,
onRestore,
}: {
suggestion: DawarichSuggestion
busy: boolean
allowJournal?: boolean
allowPlace?: boolean
showTrip?: boolean
showDate?: boolean
/** Rendered inside the phone shell, which has its own palette and its own reach. */
phone?: boolean
onAccept?: (target: DawarichSuggestionTarget) => void
onDismiss?: () => void
onRestore?: () => void
}): React.ReactElement {
const { t, locale } = useTranslation()
return (
<div className="flex items-stretch gap-[6px] px-3 py-[5px]">
{/* A stay is a card with its actions clipped to the side — the shape an
expense already has in Costs. One layout in both shells; only the
palette differs, because the phone paints from `--m-*` and the desktop
from the semantic tokens, and neither set exists outside its own. */}
<div
className={`flex min-w-0 flex-1 flex-col justify-center rounded-2xl border px-[11px] py-[9px] ${
phone ? 'border-[color:var(--m-gbr)] bg-m-card' : 'border-edge bg-surface-card'
}`}
>
{/* The name gets the line, and the one badge that is about the name
itself rides with it: "Unsure" qualifies what this place is called,
not how long somebody stayed, and two lines below the name it read as
one more fact among the times. It never pushes the name out — the
name truncates, the flag does not. */}
<p className={`flex items-center gap-1.5 font-medium ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
<span className="truncate">{suggestion.name}</span>
{/* The mark, never the word: the name is what the line is for, and
"Unsure" beside it is a caption competing with a title. The tooltip
and the accessible name still say it in full. */}
{suggestion.confidenceBand === 'low' && (
<Badge icon={Gauge} tone="warning" label={t('dawarich.badge.lowConfidence')} title={t('dawarich.confidence.low')} />
)}
</p>
{/* One badge row, one badge shape, colour carrying the meaning: neutral
for a fact, accent for a wish this fulfils, success for what was
already taken, amber for the two things worth interrupting for.
Nothing wraps — a row that reflows every time a name is long reads as
a mess, so it stays one line and the badges that do not fit are cut
off rather than pushed onto a second. */}
{/* Wraps, and only here: each badge is a fixed-height pill whose own text
never breaks, so a second line of them reads as a second line of
badges rather than as a paragraph coming apart. Cutting them off
instead would hide the wish a stay fulfils, which is the one thing in
the row worth the space. */}
<div className="mt-1 flex flex-wrap items-center gap-1">
{/* Ordered by what a reader needs first, because the rail is narrow and
the row does not wrap: how long, then what it would fulfil, then the
flags. The flags are icon-only — the word costs half the row and the
sentence under the badges says it properly anyway. */}
{showDate && (
<Badge icon={CalendarDays}>{formatDayHeading(suggestion.localDate, locale)}</Badge>
)}
<Badge icon={Clock}>{timeRange(suggestion.startedAt, suggestion.endedAt)}</Badge>
<Badge icon={Hourglass}>{formatDuration(suggestion.durationMinutes, t)}</Badge>
{suggestion.state === 'accepted' && (
<Badge icon={Check} tone="success">
{t('dawarich.suggestions.acceptedAs.' + (suggestion.target ?? 'journal'))}
</Badge>
)}
{suggestion.state === 'dismissed' && (
<Badge icon={X} tone="quiet">{t('dawarich.suggestions.dismissed')}</Badge>
)}
{showTrip && suggestion.tripTitle && (
<Badge icon={Briefcase}>{suggestion.tripTitle}</Badge>
)}
{suggestion.sourceMissing && (
<Badge icon={Unplug} tone="warning" label={t('dawarich.badge.sourceMissing')} />
)}
{!suggestion.sourceMissing && suggestion.sourceChanged && (
<Badge icon={History} tone="warning" label={t('dawarich.badge.sourceChanged')} />
)}
</div>
{/* The long form of the two warnings, under the badges: the badge is the
flag, this is what it means, and somebody reading the panel for the
first time should not have to hover to find out. */}
{(suggestion.sourceChanged || suggestion.sourceMissing) && (
<p className={`mt-1 flex items-start gap-1.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>
<AlertTriangle className="w-3 h-3 flex-shrink-0 mt-px text-warning" />
{suggestion.sourceMissing
? t('dawarich.suggestions.sourceMissing')
: t('dawarich.suggestions.sourceChanged')}
</p>
)}
</div>
{/* Always on screen. Hiding them until hover made the only two things a row
is for invisible to anyone who had not discovered that hovering did
something — and on a touch screen there is no hover at all.
A column beside the text in the desktop rail, which is narrow and tall;
a row on a phone, where there is width to spare and a stack of small
squares reads as a scrollbar. Centred either way, against the name and
the time it belongs to. */}
{onAccept && (
<div className={ACTIONS(phone)}>
{allowJournal && (
<RowAction
label={t('dawarich.suggestions.asJournal')}
icon={<BookOpen className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={() => onAccept('journal')}
/>
)}
{allowPlace && (
<RowAction
label={t('dawarich.suggestions.asPlace')}
icon={<MapPin className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={() => onAccept('place')}
/>
)}
{onDismiss && (
<RowAction
label={t('dawarich.suggestions.dismiss')}
icon={<X className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={onDismiss}
/>
)}
</div>
)}
{onRestore && (
<div className={ACTIONS(phone)}>
<RowAction
label={t('dawarich.suggestions.restore')}
icon={<RotateCcw className="w-3.5 h-3.5" />}
disabled={busy}
phone={phone}
onClick={onRestore}
/>
</div>
)}
</div>
)
}
/**
* Where a row keeps its actions: a pill clipped to the side of the card, the
* same in both shells, painted from whichever palette the shell has.
*/
const ACTIONS = (phone: boolean): string =>
'flex flex-none flex-col gap-1 rounded-full border p-[5px] ' +
(phone ? 'border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]' : 'border-edge bg-surface')
/**
* One action on a row: a real target with a real accessible name.
*
* A square with a border rather than a bare glyph, because a bare glyph on a
* white rail reads as decoration; this reads as a button, which is what it is.
* The label is a tooltip and the accessible name — spelling it out beside the
* icon would cost the row the width its name needs.
*
* All of them look the same. Marking one as the primary action would be TREK
* deciding for the reader which of "put it on the map" and "write about it" they
* meant, and the whole review step exists because that is theirs to decide.
*
* Hover fills the square with the inverse surface — near-black in the light
* scheme, near-white in the dark one — so the button somebody is about to press
* is unmistakable without inventing a colour the theme does not have.
*/
function RowAction({
label,
icon,
disabled,
phone = false,
onClick,
}: {
label: string
icon: React.ReactNode
disabled?: boolean
phone?: boolean
onClick: () => void
}): React.ReactElement {
const button = (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full transition-all disabled:opacity-40 ${
phone
? 'text-m-muted active:scale-95'
: 'text-content-secondary hover:bg-inverse hover:text-inverse-text'
}`}
>
{icon}
</button>
)
// No tooltip on a phone: there is no hover to open it with, and the label is
// already the button's accessible name.
return phone ? button : <Tooltip label={label} placement="left">{button}</Tooltip>
}
/**
* Where the data came from and how fresh it is, in one line under the title.
*
* Relative rather than a stamp: "checked 2 minutes ago" answers the question
* somebody actually has, and "9/12/2026, 6:40:19 PM" makes them do the
* subtraction themselves — on two lines, seconds included.
*/
function statusLine(
state: ReturnType<typeof useDawarichSuggestions>,
openCount: number,
t: (key: string, params?: Record<string, unknown>) => string,
locale: string,
): string {
if (state.status === 'loading') return t('dawarich.suggestions.loading')
if (state.status === 'disconnected') return t('dawarich.suggestions.notConnected')
if (state.status === 'offline') return t('dawarich.trail.offline')
if (state.status === 'unavailable') return t('dawarich.suggestions.unavailable')
// Just the freshness. The count sits in the badge beside this line, and
// gluing the two together with a separator said the same thing twice.
return state.lastSyncAt
? t('dawarich.checkedAgo', { ago: relativeTime(Date.parse(state.lastSyncAt), locale) })
: t('dawarich.neverSynced')
}
@@ -0,0 +1,188 @@
// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-014
/**
* The Atlas dialog's arithmetic, with no React around it.
*
* Everything here is small enough to look correct and wrong enough to matter if
* it is not. Three of the five carry a real trap:
*
* 1. `orderedMatches` sorts on two keys with a hand-written comparator that
* never returns 0. That is fine for ordering and a hazard for anything else,
* so the cases below check that the order is the same whichever way round
* the input arrives and that two wishes reached at the same moment both
* survive the sort rather than one of them being swapped away.
* 2. `countryLabel` runs on whatever `Intl` the device shipped. A WebView
* without `Intl.DisplayNames` throws, one with a partial region table
* answers `undefined`, and both have to end up as Dawarich's own spelling
* rather than as a crash or an empty label beside a flag.
* 3. `formatDistance` switches units at exactly a kilometre, which is the one
* input a refactor gets wrong.
*/
import { describe, it, expect } from 'vitest'
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
import {
cityLine,
countryLabel,
countryWindow,
formatDistance,
newCountries,
orderedMatches,
} from './dawarichAtlasModel'
function match(over: Partial<DawarichBucketMatch> & { itemId: number }): DawarichBucketMatch {
return {
itemId: over.itemId,
name: over.name ?? `Wish ${over.itemId}`,
match: over.match === undefined ? { at: '2026-09-01T10:00:00Z', minutes: 90, distanceMeters: 40, points: 12 } : over.match,
alreadyVisited: over.alreadyVisited ?? false,
}
}
function country(over: Partial<DawarichAtlasCountry> & { countryCode: string }): DawarichAtlasCountry {
return {
countryCode: over.countryCode,
sourceName: over.sourceName ?? over.countryCode,
cities: over.cities ?? [],
alreadyVisited: over.alreadyVisited ?? false,
}
}
const t = (key: string, params?: Record<string, unknown>): string =>
`${key}:${JSON.stringify(params ?? {})}`
describe('countryWindow', () => {
it('FE-DAWARICH-ATLASMODEL-001: asks for exactly the year before the given moment', () => {
const now = new Date('2026-09-12T18:00:00Z')
const { from, to } = countryWindow(now)
expect(to).toBe('2026-09-12T18:00:00.000Z')
expect(from).toBe('2025-09-12T18:00:00.000Z')
})
})
describe('orderedMatches', () => {
it('FE-DAWARICH-ATLASMODEL-002: drops wishes the recordings found nothing for', () => {
const ordered = orderedMatches([match({ itemId: 1, match: null }), match({ itemId: 2 })])
expect(ordered.map(m => m.itemId)).toEqual([2])
})
it('FE-DAWARICH-ATLASMODEL-003: open wishes lead, already ticked ones follow', () => {
const ordered = orderedMatches([
match({ itemId: 1, alreadyVisited: true }),
match({ itemId: 2 }),
])
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
})
it('FE-DAWARICH-ATLASMODEL-004: within a group the most recent visit comes first', () => {
const ordered = orderedMatches([
match({ itemId: 1, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
match({ itemId: 2, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
])
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
})
it('FE-DAWARICH-ATLASMODEL-005: leaves the caller\'s array untouched', () => {
const input = [match({ itemId: 1, alreadyVisited: true }), match({ itemId: 2 })]
orderedMatches(input)
expect(input.map(m => m.itemId)).toEqual([1, 2])
})
it('FE-DAWARICH-ATLASMODEL-011: the order they arrive in does not change the order they are shown in', () => {
// Both arms of both comparisons. A comparator that answers consistently one
// way round and not the other sorts whatever the scan happened to return,
// which is a list that reshuffles itself between two identical scans.
const ticked = match({ itemId: 1, alreadyVisited: true })
const openWish = match({ itemId: 2 })
expect(orderedMatches([ticked, openWish]).map(m => m.itemId)).toEqual([2, 1])
expect(orderedMatches([openWish, ticked]).map(m => m.itemId)).toEqual([2, 1])
const older = match({ itemId: 3, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
const newer = match({ itemId: 4, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
expect(orderedMatches([older, newer]).map(m => m.itemId)).toEqual([4, 3])
expect(orderedMatches([newer, older]).map(m => m.itemId)).toEqual([4, 3])
})
it('FE-DAWARICH-ATLASMODEL-012: two wishes reached in the same moment both survive the sort', () => {
// The comparator never answers 0, so equal timestamps are reported as "swap"
// in both directions. Ordering them either way is fine; losing one is not.
const at = '2026-08-02T11:00:00Z'
const ordered = orderedMatches([
match({ itemId: 1, match: { at, minutes: 95, distanceMeters: 30, points: 40 } }),
match({ itemId: 2, match: { at, minutes: 120, distanceMeters: 55, points: 51 } }),
])
expect(ordered.map(m => m.itemId).sort((a, b) => a - b)).toEqual([1, 2])
})
})
describe('newCountries', () => {
it('FE-DAWARICH-ATLASMODEL-006: only the ones a confirmation would actually change', () => {
const offered = newCountries([
country({ countryCode: 'DE', alreadyVisited: true }),
country({ countryCode: 'NL' }),
])
expect(offered.map(c => c.countryCode)).toEqual(['NL'])
})
})
describe('countryLabel', () => {
it('FE-DAWARICH-ATLASMODEL-007: names the country in the reader\'s language', () => {
expect(countryLabel('NL', 'Netherlands', 'de')).toBe('Niederlande')
})
it('FE-DAWARICH-ATLASMODEL-008: falls back to Dawarich\'s own spelling for a code it cannot name', () => {
// Not an ISO region: Intl either throws or echoes it back, and neither is a
// name a reader would recognise.
expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia')
})
it('FE-DAWARICH-ATLASMODEL-013: a runtime whose Intl names nothing still labels the row', () => {
// The other half of the fallback, and the one no desktop browser reproduces:
// a WebView that ships Intl.DisplayNames with a partial region table answers
// `undefined` instead of throwing, and an empty string beside a flag is a
// row nobody can identify.
const real = Intl.DisplayNames
Object.defineProperty(Intl, 'DisplayNames', {
configurable: true,
writable: true,
value: class {
of(): string | undefined {
return undefined
}
},
})
try {
expect(countryLabel('NL', 'Netherlands', 'en')).toBe('Netherlands')
} finally {
Object.defineProperty(Intl, 'DisplayNames', { configurable: true, writable: true, value: real })
}
})
})
describe('cityLine', () => {
it('FE-DAWARICH-ATLASMODEL-009: lists the first few cities and stops', () => {
const cities = ['Cologne', 'Bonn', 'Aachen', 'Trier'].map(name => ({
name,
minutes: 60,
lastSeenAt: '2026-09-01T10:00:00Z',
}))
expect(cityLine(cities)).toBe('Cologne, Bonn, Aachen')
expect(cityLine(cities, 1)).toBe('Cologne')
expect(cityLine([])).toBe('')
})
})
describe('formatDistance', () => {
it('FE-DAWARICH-ATLASMODEL-010: metres up to a kilometre, then kilometres with one decimal', () => {
expect(formatDistance(49.4, t)).toBe('dawarich.bucket.metersAway:{"meters":49}')
expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}')
expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}')
})
it('FE-DAWARICH-ATLASMODEL-014: exactly a kilometre is already a kilometre', () => {
// The switch is `< 1000`, so 1000 belongs to the other unit. Off by one here
// reads as "1000 m away", which is the phrasing the rule exists to avoid.
expect(formatDistance(1000, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
// One decimal, rounded, not truncated: 1049 and 1051 are not the same walk.
expect(formatDistance(1049, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
expect(formatDistance(1051, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.1"}')
})
})
@@ -0,0 +1,84 @@
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
/**
* Pure shaping for the Atlas dialog — no React, so the ordering, the naming and
* the window arithmetic can be tested without rendering anything.
*/
/**
* A year back from the given moment.
*
* Long enough to cover the trips someone would be filling in, short enough that
* a self-hosted instance is not asked to aggregate a decade of points for a card
* nobody was looking at. Takes `now` rather than reading the clock so the window
* is testable.
*/
export function countryWindow(now: Date): { from: string; to: string } {
const from = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
return { from: from.toISOString(), to: now.toISOString() }
}
/**
* The wishes worth showing, best first.
*
* Only entries the recordings actually found; the ones still open lead, because
* they are the ones the reader can act on, and within each group the most recent
* visit comes first — a wish reached last week is easier to confirm than one
* reached ten months ago.
*/
export function orderedMatches(matches: DawarichBucketMatch[]): DawarichBucketMatch[] {
return matches
.filter(match => match.match !== null)
.slice()
.sort((a, b) => {
if (a.alreadyVisited !== b.alreadyVisited) return a.alreadyVisited ? 1 : -1
return (b.match?.at ?? '') < (a.match?.at ?? '') ? -1 : 1
})
}
/** The countries a confirmation would actually change. */
export function newCountries(countries: DawarichAtlasCountry[]): DawarichAtlasCountry[] {
return countries.filter(country => !country.alreadyVisited)
}
/**
* A country's name in the reader's language, falling back to Dawarich's own
* spelling.
*
* `Intl.DisplayNames` is missing on older WebViews and throws on a code it does
* not know, so both are caught — the Atlas page's own resolver does the same.
*/
export function countryLabel(code: string, sourceName: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || sourceName
} catch {
return sourceName
}
}
/**
* The first few cities, as one line.
*
* Three is what fits beside a flag at the width the dialog has on a phone, and
* the count badge beside it already says how many there were in total.
*/
export function cityLine(cities: DawarichAtlasCountry['cities'], limit = 3): string {
return cities
.slice(0, limit)
.map(city => city.name)
.join(', ')
}
/**
* A distance a person would say out loud: metres up to a kilometre, then one
* decimal of a kilometre. `1400 m away` is a measurement; `1.4 km away` is how
* far it was.
*/
export function formatDistance(
meters: number,
t: (key: string, params?: Record<string, unknown>) => string,
): string {
return meters < 1000
? t('dawarich.bucket.metersAway', { meters: Math.round(meters) })
: t('dawarich.bucket.kilometersAway', { km: (meters / 1000).toFixed(1) })
}
@@ -0,0 +1,158 @@
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-014
import { describe, it, expect } from 'vitest'
import type { DawarichSuggestion } from '@trek/shared'
import {
clockOf,
formatDayHeading,
formatDayOption,
formatDuration,
groupByDay,
timeRange,
} from './dawarichSuggestionModel'
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
return {
id: over.id,
sourceVisitId: String(over.id),
tripId: over.tripId ?? null,
tripTitle: over.tripTitle ?? null,
name: over.name ?? 'Cafe Reichard',
lat: over.lat ?? 50.94,
lng: over.lng ?? 6.96,
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
durationMinutes: over.durationMinutes ?? 145,
localDate: over.localDate,
sourceStatus: over.sourceStatus ?? 'suggested',
confidence: over.confidence ?? null,
confidenceBand: over.confidenceBand ?? null,
state: over.state ?? 'new',
target: over.target ?? null,
acceptedPlaceId: over.acceptedPlaceId ?? null,
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
sourceChanged: over.sourceChanged ?? false,
sourceMissing: over.sourceMissing ?? false,
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
matchedBucketListName: over.matchedBucketListName ?? null,
countryCode: over.countryCode ?? null,
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
}
}
const t = (key: string, params?: Record<string, unknown>): string =>
`${key}:${JSON.stringify(params ?? {})}`
describe('groupByDay', () => {
it('FE-DAWARICH-SUGMODEL-001: newest day first, earliest stay first within a day', () => {
const days = groupByDay([
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
stay({ id: 2, localDate: '2026-09-11' }),
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
])
expect(days.map(d => d.date)).toEqual(['2026-09-11', '2026-09-10'])
expect(days[1].stays.map(s => s.id)).toEqual([3, 1])
})
it('FE-DAWARICH-SUGMODEL-002: the colour counts from the oldest day, like the map does', () => {
const days = groupByDay([
stay({ id: 1, localDate: '2026-09-10' }),
stay({ id: 2, localDate: '2026-09-11' }),
])
const oldest = days.find(d => d.date === '2026-09-10')!
const newest = days.find(d => d.date === '2026-09-11')!
expect(oldest.color).not.toBe(newest.color)
// Same input in a different order paints the same day the same colour.
const again = groupByDay([
stay({ id: 2, localDate: '2026-09-11' }),
stay({ id: 1, localDate: '2026-09-10' }),
])
expect(again.find(d => d.date === '2026-09-10')!.color).toBe(oldest.color)
})
it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => {
expect(groupByDay([])).toEqual([])
})
it('FE-DAWARICH-SUGMODEL-013: a day that already reads in order is left in it', () => {
// Three stays rather than two: with two, a comparator that answered the
// same thing both ways would still look right half the time.
const input = [
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T13:30:00+02:00' }),
]
const days = groupByDay(input)
expect(days).toHaveLength(1)
expect(days[0].stays.map(s => s.id)).toEqual([1, 3, 2])
// The list handed in belongs to the store. Sorting it in place would
// reorder what the panel is rendering from underneath it.
expect(input.map(s => s.id)).toEqual([1, 2, 3])
})
})
describe('clockOf / timeRange', () => {
it('FE-DAWARICH-SUGMODEL-004: reads the clock out of the timestamp\'s own offset', () => {
expect(clockOf('2026-09-10T10:15:00+02:00')).toBe('10:15')
})
it('FE-DAWARICH-SUGMODEL-005: an unreadable timestamp has no clock', () => {
expect(clockOf('yesterday')).toBe('')
expect(clockOf('')).toBe('')
})
it('FE-DAWARICH-SUGMODEL-006: a range when both ends read, the arrival alone when the end does not', () => {
expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00')).toBe('10:15 – 12:40')
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense')).toBe('10:15')
})
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00')).toBe('')
})
})
describe('formatDuration', () => {
it('FE-DAWARICH-SUGMODEL-008: minutes under an hour, hours above it, both when there is a remainder', () => {
expect(formatDuration(45, t)).toBe('dawarich.duration.minutes:{"minutes":45}')
expect(formatDuration(120, t)).toBe('dawarich.duration.hours:{"hours":2}')
expect(formatDuration(145, t)).toBe('dawarich.duration.hoursMinutes:{"hours":2,"minutes":25}')
})
})
describe('formatDayHeading', () => {
it('FE-DAWARICH-SUGMODEL-009: reads as the local date, not as yesterday', () => {
// Built from the date parts rather than from a Date at midnight UTC, which
// is the difference between "Thu, 10 Sept" and "Wed, 9 Sept" west of UTC.
expect(formatDayHeading('2026-09-10', 'en-GB')).toContain('10')
expect(formatDayHeading('2026-09-10', 'en-GB')).toMatch(/Thu/)
})
it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => {
expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date')
})
it('FE-DAWARICH-SUGMODEL-014: a date missing its month or its day is shown as it came too', () => {
// Each guard on its own, because `Date` would rather guess than refuse:
// month 0 rolls the heading back into December of the previous year, and a
// missing day silently becomes the first of the month. Either one reads as
// a real date, which is worse than showing the string that arrived.
expect(formatDayHeading('2026-00-10', 'en-GB')).toBe('2026-00-10')
expect(formatDayHeading('2026-09-00', 'en-GB')).toBe('2026-09-00')
expect(formatDayHeading('2026-09', 'en-GB')).toBe('2026-09')
})
})
describe('formatDayOption', () => {
it('FE-DAWARICH-SUGMODEL-011: the day number is the label, the date rides along as a badge', () => {
const option = formatDayOption(3, '2026-09-10', 'en-GB', t)
expect(option.label).toBe('planner.dayN:{"n":3}')
expect(option.badge).toMatch(/10/)
})
it('FE-DAWARICH-SUGMODEL-012: a day without a date gets no badge rather than an empty one', () => {
expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined })
})
})
@@ -0,0 +1,117 @@
import type { DawarichSuggestion } from '@trek/shared'
import { dayColor } from '../Map/dawarichTrail'
/**
* Pure shaping for the suggestions panel — no React, so the grouping and the
* formatting can be tested without rendering anything.
*/
export interface SuggestionDay {
/** `YYYY-MM-DD`, the stay's own local date. */
date: string
/** The colour this day is drawn in on the map, so the panel and the line agree. */
color: string
stays: DawarichSuggestion[]
}
/**
* Group stays by the local day they happened on, newest day first, and give each
* day the colour the map draws it in.
*
* The grouping is what turns a flat list into something readable: a day of
* travel produces four or five stays, and repeating the full date on every row
* buries the one thing that distinguishes them — the time.
*
* The colour index counts days from the OLDEST, matching how the track overlay
* numbers them, so "the purple line" and "the purple day" are the same day.
*/
export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
const byDate = new Map<string, DawarichSuggestion[]>()
for (const stay of suggestions) {
const bucket = byDate.get(stay.localDate)
if (bucket) bucket.push(stay)
else byDate.set(stay.localDate, [stay])
}
const oldestFirst = [...byDate.keys()].sort()
return oldestFirst
.map((date, index) => ({
date,
color: dayColor(index),
// Within a day, earliest first: that is the order they were lived in.
stays: byDate.get(date)!.slice().sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)),
}))
.reverse()
}
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
export function clockOf(iso: string): string {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
}
/**
* `10:15 – 12:40`, or just the arrival when the end is unreadable.
*
* An en dash with hair spaces rather than a hyphen: this is a range, and it sits
* next to times often enough that the difference is visible.
*/
export function timeRange(startedAt: string, endedAt: string): string {
const from = clockOf(startedAt)
const to = clockOf(endedAt)
if (!from) return ''
return to ? `${from} – ${to}` : from
}
/**
* A duration a person would say out loud.
*
* "285 min" is a number from a database; "4 h 45 min" is how long somebody was
* somewhere. The two label keys carry the units so a locale can put them where
* it wants them.
*/
export function formatDuration(
minutes: number,
t: (key: string, params?: Record<string, unknown>) => string,
): string {
if (minutes < 60) return t('dawarich.duration.minutes', { minutes })
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest === 0
? t('dawarich.duration.hours', { hours })
: t('dawarich.duration.hoursMinutes', { hours, minutes: rest })
}
/**
* A stay's day header, in the reader's language: `Thu, 10 Sept`.
*
* Built from the date string rather than from a Date at midnight UTC, so a
* reader east or west of UTC does not see yesterday.
*/
export function formatDayHeading(date: string, locale: string): string {
const [year, month, day] = date.split('-').map(Number)
if (!year || !month || !day) return date
return new Date(year, month - 1, day).toLocaleDateString(locale, {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
/**
* How a trip day reads in the accept dialog's picker.
*
* `1. 2026-09-09` is a row out of a database. What a person recognises is the
* number the planner gives the day and the weekday it fell on, which is exactly
* what the day rail shows them — so the picker says the same thing.
*/
export function formatDayOption(
dayNumber: number,
date: string | null | undefined,
locale: string,
t: (key: string, params?: Record<string, unknown>) => string,
): { label: string; badge?: string } {
return {
label: t('planner.dayN', { n: dayNumber }),
badge: date ? formatDayHeading(date, locale) : undefined,
}
}
@@ -0,0 +1,112 @@
/**
* FE-DAWARICH-API-030 to FE-DAWARICH-API-032
*
* Two lines of code, and both of them are the kind that rot without a sound.
*
* Accepting a recorded stay into a trip is written entirely server-side, so the
* planner learns nothing about the place it produced until something pulls the
* trip back in. This is that something, and both trip shells (the desktop
* `PlacesSidebar` and the phone `MPlacesBrowser`) call it as a one-liner
* precisely so the two copies cannot drift apart. What the tests hold is:
*
* - **Both refreshes are started, not one.** A stay accepted onto a day creates
* an assignment as well as a place, so refreshing only the places leaves the
* day rail one entry short until a reload. That is a plausible "simplification"
* for someone reading the call site, and nothing else in the app would notice.
* - **They run together, not in sequence.** `Promise.all` fires both before
* awaiting either; rewritten as two awaits it still works, just twice as
* slowly, and the button keeps spinning for both round trips. The order check
* below is synchronous, so it distinguishes the two shapes without timing.
* - **The id goes through verbatim.** The phone shell hands over a route param,
* which is a string; the desktop one hands over a number. Coercing either
* direction here would break the other caller.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import { useTripStore } from '../../store/tripStore'
import { refreshTripAfterAccept } from './dawarichTripRefresh'
const original = {
refreshPlaces: useTripStore.getState().refreshPlaces,
refreshDays: useTripStore.getState().refreshDays,
}
afterEach(() => {
useTripStore.setState(original)
})
describe('refreshTripAfterAccept', () => {
it('FE-DAWARICH-API-030: starts the day refresh without waiting for the place refresh', async () => {
const order: string[] = []
let releasePlaces!: () => void
// A gate rather than a timer: with Promise.all both calls are made before
// anything is awaited, so the day refresh has already run by the time control
// returns. Rewritten as `await a; await b` it would still be waiting here.
const placesGate = new Promise<void>(resolve => {
releasePlaces = resolve
})
useTripStore.setState({
refreshPlaces: vi.fn(async (_tripId: number | string) => {
order.push('places:called')
await placesGate
order.push('places:settled')
}),
refreshDays: vi.fn(async (_tripId: number | string) => {
order.push('days:called')
}),
})
const pending = refreshTripAfterAccept(7)
expect(order).toEqual(['places:called', 'days:called'])
releasePlaces()
await pending
expect(order).toEqual(['places:called', 'days:called', 'places:settled'])
})
it('FE-DAWARICH-API-031: resolves only once both refreshes have finished', async () => {
let releaseDays!: () => void
const daysGate = new Promise<void>(resolve => {
releaseDays = resolve
})
let settled = false
useTripStore.setState({
refreshPlaces: vi.fn(async (_tripId: number | string) => {}),
refreshDays: vi.fn(async (_tripId: number | string) => {
await daysGate
}),
})
const pending = refreshTripAfterAccept(7).then(() => {
settled = true
})
// Drain the microtask queue: the places half is already done, so anything
// that only awaited that one would be finished by now.
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
releaseDays()
await pending
expect(settled).toBe(true)
})
it('FE-DAWARICH-API-032: hands both refreshes the id exactly as the caller gave it', async () => {
const refreshPlaces = vi.fn(async (_tripId: number | string) => {})
const refreshDays = vi.fn(async (_tripId: number | string) => {})
useTripStore.setState({ refreshPlaces, refreshDays })
// The phone shell reads the trip id off the route, so it is a string there
// and a number on the desktop. Both repos accept either; a cast added here
// would only break whichever caller was not the one being looked at.
await refreshTripAfterAccept('42')
expect(refreshPlaces).toHaveBeenCalledWith('42')
expect(refreshDays).toHaveBeenCalledWith('42')
await refreshTripAfterAccept(42)
expect(refreshPlaces).toHaveBeenLastCalledWith(42)
expect(refreshDays).toHaveBeenLastCalledWith(42)
})
})
@@ -0,0 +1,18 @@
import { useTripStore } from '../../store/tripStore'
/**
* Pull the trip back in after a stay was accepted into it.
*
* The acceptance is written server-side, so the planner knows nothing about the
* place it produced until somebody reloads the page — which is exactly what a
* person should never have to do after pressing a button. Both trip shells call
* this, which is also why it is a plain function rather than a hook: one line at
* each call site keeps the desktop and phone copies from drifting.
*
* Places and days both, because a stay accepted onto a day creates an assignment
* as well, and the day rail would otherwise stay one place short.
*/
export async function refreshTripAfterAccept(tripId: number | string): Promise<void> {
const store = useTripStore.getState()
await Promise.all([store.refreshPlaces(tripId), store.refreshDays(tripId)])
}
@@ -0,0 +1,55 @@
// FE-JRN-SCRUBBER-001 to FE-JRN-SCRUBBER-005
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { render, screen } from '../../../tests/helpers/render'
import JourneyDayScrubber from './JourneyDayScrubber'
const DAYS = [
{ date: '2026-03-15', color: '#6366f1' },
{ date: '2026-03-16', color: '#f97316' },
{ date: '2026-03-17', color: '#14b8a6' },
]
describe('JourneyDayScrubber', () => {
it('FE-JRN-SCRUBBER-001: one segment per day', () => {
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={() => {}} />)
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('FE-JRN-SCRUBBER-002: says which day you are on, by its date', () => {
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-16" onPick={() => {}} />)
// The long date is the label the reader was missing on the cards. "Day 2"
// stood beside it for a while and was the same fact told twice.
expect(screen.getByText(/16/)).toBeInTheDocument()
expect(screen.queryByText(/^Day \d/)).toBeNull()
})
it('FE-JRN-SCRUBBER-003: marks the day it is on', () => {
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-17" onPick={() => {}} />)
const marked = screen.getAllByRole('button').filter(b => b.getAttribute('aria-current') === 'true')
expect(marked).toHaveLength(1)
})
it('FE-JRN-SCRUBBER-004: a tap asks for that day', async () => {
const onPick = vi.fn()
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={onPick} />)
await userEvent.click(screen.getAllByRole('button')[2])
expect(onPick).toHaveBeenCalledWith('2026-03-17')
})
it('FE-JRN-SCRUBBER-005: a one-day journey has nothing to scrub', () => {
// The bar would say only what the card's own date already says, and it would
// take a row of the map to do it.
const { container } = render(
<JourneyDayScrubber days={[DAYS[0]]} activeDate="2026-03-15" onPick={() => {}} />,
)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,121 @@
import { useRef } from 'react'
import { useTranslation } from '../../i18n'
import type { JourneyDay } from './journeyCard'
/**
* The day bar above the phone timeline.
*
* A horizontal carousel of cards tells you what happened but never where you are
* in the trip: scrolling for a while gives no sense of having crossed from the
* second day into the third, and there is no way to reach day nine except by
* swiping past days three to eight (discussion #2299). One segment per day fixes
* both — it says how long the journey is, how far in you are, and it takes you
* anywhere in one tap or one drag along it.
*
* The segment colours are the same `DAY_COLORS` the map markers and the card
* edges use, so the bar is a legend for the whole screen rather than a fourth
* thing to learn.
*/
interface Props {
days: JourneyDay[]
activeDate: string | null
onPick: (date: string) => void
}
export default function JourneyDayScrubber({ days, activeDate, onPick }: Props) {
const { t, locale } = useTranslation()
const barRef = useRef<HTMLDivElement>(null)
const dragging = useRef(false)
const lastPicked = useRef<string | null>(null)
// One day is not a journey to scrub through, and the bar would say nothing the
// card's own date does not already say.
if (days.length < 2) return null
const long = (date: string) =>
new Date(date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
const activeIndex = activeDate ? days.findIndex(d => d.date === activeDate) : -1
const shown = activeIndex >= 0 ? days[activeIndex] : days[0]
/**
* The day under a given screen x, measured across the bar.
*
* Measured against the bar even while the finger is on the date above it, so a
* drag that starts on the label still lands on the right day.
*/
const pickAt = (clientX: number) => {
const rect = barRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) return
const ratio = (clientX - rect.left) / rect.width
const index = Math.min(days.length - 1, Math.max(0, Math.floor(ratio * days.length)))
const day = days[index]
if (!day || day.date === lastPicked.current) return
lastPicked.current = day.date
onPick(day.date)
}
return (
// touch-none, and the events stop here: the whole thing lies on a map that
// pans, and a drag along the days must not drag the world with it.
<div
className="pointer-events-auto touch-none px-4 pb-[6px] select-none"
onPointerDown={e => {
dragging.current = true
lastPicked.current = shown.date
e.currentTarget.setPointerCapture?.(e.pointerId)
e.stopPropagation()
pickAt(e.clientX)
}}
onPointerMove={e => {
if (!dragging.current) return
e.stopPropagation()
pickAt(e.clientX)
}}
onPointerUp={e => {
dragging.current = false
e.currentTarget.releasePointerCapture?.(e.pointerId)
}}
onPointerCancel={() => { dragging.current = false }}
>
{/* The date, and the handle: press it and slide sideways to run through the
days. "Day 3" stood beside it for a while and was the same fact told
twice, since the bar below already shows how far along that is. */}
<div className="mb-[5px] flex items-baseline">
<span className="cursor-grab rounded-full bg-black/55 px-[9px] py-[3px] text-[11px] font-bold whitespace-nowrap text-white backdrop-blur-[3px] active:cursor-grabbing">
{long(shown.date)}
</span>
</div>
{/* Buttons as well as the drag, because a tap is the whole gesture for most
people and a button is the only version of this a keyboard can reach.
The bar you see is a few pixels tall; the button around it is 20, because
a 3px target on a phone is not a target. */}
<div ref={barRef} className="flex items-stretch gap-[3px]">
{days.map(day => {
const on = day.date === shown.date
return (
<button
key={day.date}
type="button"
onClick={() => onPick(day.date)}
aria-label={t('journey.detail.jumpToDay', { date: long(day.date) })}
aria-current={on ? 'true' : undefined}
className="flex min-w-[6px] flex-1 items-end py-[7px]"
>
<span
className="block w-full rounded-full transition-all duration-150"
style={{
height: on ? 6 : 3,
background: day.color,
opacity: on ? 1 : 0.42,
boxShadow: on ? '0 0 0 1.5px rgba(255,255,255,.75)' : '0 0 0 1px rgba(0,0,0,.18)',
}}
/>
</button>
)
})}
</div>
</div>
)
}
@@ -126,10 +126,14 @@ describe('EntryCard', () => {
expect(screen.getByText('food')).toBeInTheDocument()
})
it('FE-JRN-CARD-009: renders the pros/cons verdict and the story body', () => {
it('FE-JRN-CARD-009: renders the story body, and the verdict once the fold is opened', async () => {
// The verdict moved behind the fold in discussion #2299 — see FE-JRN-CARD-020.
mountCard(buildEntry({ story: 'A wonderful evening', pros_cons: { pros: ['Great food'], cons: ['Crowded'] } }))
expect(screen.getByText('A wonderful evening')).toBeInTheDocument()
await userEvent.click(screen.getByText('Show more'))
expect(screen.getByText('Great food')).toBeInTheDocument()
expect(screen.getByText('Crowded')).toBeInTheDocument()
})
@@ -258,3 +262,62 @@ describe('CheckinCard', () => {
expect(container.querySelector('.cursor-pointer')).not.toBeInTheDocument()
})
})
describe('the fold (discussion #2299)', () => {
it('FE-JRN-CARD-020: the verdict rides behind the story fold', async () => {
// A feed of open pro/con tables reads as a spreadsheet. The verdict belongs to
// one entry, so it comes out when that entry is opened.
render(
<EntryCard
entry={buildEntry({ story: 'A short line.', pros_cons: { pros: ['Great coffee'], cons: ['Loud'] } })}
onEdit={() => {}}
onDelete={() => {}}
onPhotoClick={() => {}}
/>,
)
expect(screen.queryByText('Great coffee')).toBeNull()
await userEvent.click(screen.getByText('Show more'))
expect(screen.getByText('Great coffee')).toBeInTheDocument()
expect(screen.getByText('Loud')).toBeInTheDocument()
})
it('FE-JRN-CARD-021: a verdict on an entry with no story stays in the open', () => {
// There is no fold to put it behind, and hiding it behind a button that says
// "show more story" when there is no story would be a lie.
render(
<EntryCard
entry={buildEntry({ story: null, pros_cons: { pros: ['Great coffee'], cons: [] } })}
onEdit={() => {}}
onDelete={() => {}}
onPhotoClick={() => {}}
/>,
)
expect(screen.getByText('Great coffee')).toBeInTheDocument()
expect(screen.queryByText('Show more')).toBeNull()
})
})
describe('dismissing a suggestion', () => {
it('FE-JRN-CARD-022: a suggestion offers a way out, and the way out is not the card itself', async () => {
const onClick = vi.fn()
const onDismiss = vi.fn()
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} onClick={onClick} onDismiss={onDismiss} />)
await userEvent.click(screen.getByRole('button', { name: 'Dismiss this suggestion' }))
expect(onDismiss).toHaveBeenCalledTimes(1)
// The card underneath opens the editor; dismissing must not do both.
expect(onClick).not.toHaveBeenCalled()
})
it('FE-JRN-CARD-023: a reader who cannot edit is offered nothing to dismiss', () => {
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} />)
expect(screen.queryByRole('button', { name: 'Dismiss this suggestion' })).toBeNull()
})
})
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react'
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff, X } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import { useTranslation } from '../../i18n'
import { pluginsApi } from '../../api/client'
@@ -8,6 +8,7 @@ import { usePluginStore } from '../../store/pluginStore'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { Tooltip } from '../shared/Tooltip'
import { PhotoGrid } from './JourneyDetailPagePhotoGrid'
import { MoodChip, WeatherChip } from './JourneyDetailPageChips'
import { ExpandableStory } from './JourneyDetailPageExpandableStory'
@@ -152,13 +153,16 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{!photos.length && entry.location_name && !entry.title && (
<div className="mb-2" />
)}
{entry.story && (
<ExpandableStory story={entry.story} />
)}
{/* Pros & Cons — "Pros & Cons" style */}
{hasProscons && (
<VerdictSection pros={prosArr} cons={consArr} />
{/* The verdict rides behind the story's fold: it belongs to one entry, and a
feed of open pro/con tables is a spreadsheet rather than a journal. An
entry with no story keeps it in the open, since there is no fold to
put it behind. */}
{entry.story ? (
<ExpandableStory story={entry.story}>
{hasProscons && <VerdictSection pros={prosArr} cons={consArr} />}
</ExpandableStory>
) : (
hasProscons && <VerdictSection pros={prosArr} cons={consArr} />
)}
{(mood || weather || (entry.tags && entry.tags.length > 0)) && (
@@ -193,7 +197,16 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
)
}
export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
/**
* A place the linked trip planned, offered as an entry waiting to be written.
*
* `onDismiss` is the way out of one that will never be written: plans change, and
* a journey used to have no answer to a suggestion for a museum the traveller
* skipped except hiding every suggestion at once (discussion #2299). The row is
* kept server-side so the trip sync does not offer it again, and the journey
* settings sheet brings them all back.
*/
export function SkeletonCard({ entry, onClick, onDismiss }: { entry: JourneyEntry; onClick?: () => void; onDismiss?: () => void }) {
const { t } = useTranslation()
return (
<div
@@ -220,6 +233,19 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?
<Plus size={12} strokeWidth={2.6} /> {t('journey.detail.addEntry')}
</span>
)}
{onDismiss && (
<Tooltip label={t('journey.suggestions.dismiss')} placement="top">
<button
type="button"
onClick={e => { e.stopPropagation(); onDismiss() }}
aria-label={t('journey.suggestions.dismiss')}
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center opacity-45 hover:opacity-100 transition-opacity"
style={{ color: 'var(--vg-ink3)' }}
>
<X size={14} strokeWidth={2.4} />
</button>
</Tooltip>
)}
</div>
)
}
@@ -2,9 +2,10 @@ import { useEffect, useMemo, useState, useRef } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
import { journeyApi, mapsApi, addonsApi, memoriesApi } from '../../api/client'
import { journeyApi, mapsApi, addonsApi, memoriesApi, weatherApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types'
@@ -16,16 +17,27 @@ import { DatePicker } from './JourneyDetailPageDatePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourneyMeta'
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, 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
journeyId: number
tripDates: Set<string>
galleryPhotos: GalleryPhoto[]
trips: JourneyTrip[]
userId?: number
/**
* The optional fields this journey still keeps (discussion #2299).
*
* A field switched off disappears from the form but keeps whatever an entry
* already holds: the value is not cleared, and switching it back on brings it
* into view again. Nobody loses a verdict they wrote by tidying a form.
*/
showVerdict?: boolean
showMood?: boolean
showWeather?: boolean
onClose: () => void
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
@@ -73,6 +85,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
// camera, so tablets and laptops get the same route the phone sheet already has.
const cameraRef = useRef<HTMLInputElement>(null)
const storyRef = useRef<HTMLTextAreaElement>(null)
// Which verdict row to put the caret in after the next render. Enter adds a row
// and the caret has to follow it, or the key does half a job.
const verdictFocusRef = useRef<string | null>(null)
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
// Track which fields differ from the entry we started editing so we can
@@ -133,6 +148,66 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
})
}
/**
* Fill the weather in from the forecast once the entry knows where and when.
*
* The phone's quick capture has done this since it was built; typing an entry
* up at a desk was the one place you still picked the icon by hand (discussion
* #2299). The date decides the source on the server: today comes from the
* forecast, a backdated day from the ERA5 archive, so writing up last Tuesday
* gets last Tuesday's weather rather than this afternoon's.
*
* Only ever fills an empty field, and each place-and-day is tried once, so a
* cleared icon stays cleared and a chosen one is never overwritten.
*/
const weatherTriedRef = useRef<string | null>(null)
useEffect(() => {
if (!showWeather) return
if (typeof locationLat !== 'number' || typeof locationLng !== 'number') return
if (weather) return
const key = `${locationLat.toFixed(3)},${locationLng.toFixed(3)},${entryDate}`
if (weatherTriedRef.current === key) return
weatherTriedRef.current = key
let active = true
weatherApi.get(locationLat, locationLng, entryDate, language)
.then(result => {
// An error-shaped answer carries no `main`, and the dev-only schema check
// does not stop it reaching here in production.
if (!active || !result || result.error || typeof result.main !== 'string') return
const category = journeyWeatherCategory(result.main, result.description ?? '')
// Re-checked rather than trusted from the closure: the request is a
// round trip and the traveller may have picked an icon while it was out.
setWeather(current => current || category)
})
.catch(() => { /* no weather is a fine outcome for a journal entry */ })
return () => { active = false }
}, [showWeather, locationLat, locationLng, entryDate, weather, language])
/**
* Enter in a pro or con opens the next one, the way every list of short things
* behaves. Reaching for the plus button between every item was the complaint
* (discussion #2299); the button stays for the mouse.
*
* The new row goes directly below the one you are in rather than at the end, so
* a thought inserted in the middle lands where you meant it.
*/
const addVerdictRow = (list: 'pros' | 'cons', index: number) => {
const [values, setValues] = list === 'pros' ? [pros, setPros] as const : [cons, setCons] as const
const next = [...values]
next.splice(index + 1, 0, '')
setValues(next)
verdictFocusRef.current = `${list}-${index + 1}`
}
/** Give the caret to the row `addVerdictRow` just made, once React has drawn it. */
const verdictRowRef = (key: string) => (el: HTMLInputElement | null) => {
if (el && verdictFocusRef.current === key) {
verdictFocusRef.current = null
el.focus()
}
}
const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
@@ -281,7 +356,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
/>
<div>
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<input ref={fileRef} type="file" accept="image/*,video/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<div className="flex gap-2">
<button type="button"
@@ -307,12 +382,16 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
{/* Only where a camera is plausibly attached to the thing you are typing
on. On a desktop it was a second button to the same file dialog with a
different icon (discussion #2299) — the phone shell has its own sheet,
and this modal is what a tablet gets. */}
<button type="button"
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
disabled={saving}
aria-label={t('journey.photo.add')}
title={t('journey.photo.add')}
className="border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
className="md:hidden border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
>
<Camera size={14} />
</button>
@@ -500,7 +579,14 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
))}
{pendingFiles.map((f, i) => (
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
{/* A clip in an <img> is a broken-image glyph (issue #2341). The
same object URL in a <video> shows its first frame, which is
the preview the poster frame will become after saving. */}
{isVideoFile(f) ? (
<video src={pendingUrls[i]} className="w-full h-full object-cover" muted playsInline preload="metadata" />
) : (
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
)}
<button type="button"
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
@@ -530,7 +616,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
<div className="flex flex-col gap-4 min-w-0">
{/* Pros & Cons */}
{/* Pros & Cons — gone entirely when the journey does not keep a verdict */}
{showVerdict && (
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl p-5">
<div className="mb-4">
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.editor.prosCons')}</span>
@@ -549,8 +636,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
<input
ref={verdictRowRef(`pros-${i}`)}
value={p}
onChange={e => { const next = [...pros]; next[i] = e.target.value; setPros(next) }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addVerdictRow('pros', i) } }}
placeholder={t('journey.editor.proPlaceholder')}
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
/>
@@ -562,7 +651,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
))}
<button type="button"
onClick={() => setPros([...pros, ''])}
onClick={() => addVerdictRow('pros', pros.length - 1)}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
>
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
@@ -583,8 +672,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
<input
ref={verdictRowRef(`cons-${i}`)}
value={c}
onChange={e => { const next = [...cons]; next[i] = e.target.value; setCons(next) }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addVerdictRow('cons', i) } }}
placeholder={t('journey.editor.conPlaceholder')}
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
/>
@@ -596,7 +687,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
))}
<button type="button"
onClick={() => setCons([...cons, ''])}
onClick={() => addVerdictRow('cons', cons.length - 1)}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
>
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
@@ -605,14 +696,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
{/* The date needs the room, not the clock: a long localized date
("12. Sept. 2026") wrapped onto a second line while the time field
sat half empty beside it. The row is split in the date's favour and
the location, which is a free-text search, gives some back. */}
<div className="grid grid-cols-1 sm:grid-cols-[1.3fr_1fr] gap-3">
{/* Time sat in state and went to the server, it just had no input here — so a
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
and the public share, and the desktop had no way to correct it (#1614). */}
{/* 136px, not 104: the custom picker adds a clock button and, in 12h,
shows "2:30 PM" where the native input showed a fixed-width HH:MM. */}
<div className="grid grid-cols-[1fr_136px] gap-2">
{/* 112px: enough for the clock button plus "2:30 PM" in 12h, and no
more — the date column is what needed the space back. */}
<div className="grid grid-cols-[minmax(0,1fr)_112px] gap-2">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
@@ -716,6 +812,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
)}
{showMood && (
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
<div className="flex flex-wrap gap-2">
@@ -735,7 +832,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
})}
</div>
</div>
)}
{showWeather && (
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.weather')}</label>
<div className="flex flex-wrap gap-2">
@@ -754,6 +853,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
})}
</div>
</div>
)}
</div>
</div>
</div>
@@ -1,9 +1,21 @@
import { useEffect, useState, useRef, type HTMLAttributes } from 'react'
import { useEffect, useState, useRef, type HTMLAttributes, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { useTranslation } from '../../i18n'
import JournalBody from './JournalBody'
export function ExpandableStory({ story }: { story: string }) {
/**
* The story, folded, with whatever else belongs behind the fold.
*
* Nine lines was most of a screen: a feed of long entries read as a wall, and the
* fold only earned its keep on the very longest of them (discussion #2299). Four
* lines is enough to know whether this is the entry you were looking for.
*
* `children` is the rest of what unfolding reveals — today the pros and cons,
* which are a detail of one entry rather than something to scan a journey by.
* They keep the toggle alive on their own, so an entry with a two-line story and
* a verdict still offers a way to see it.
*/
export function ExpandableStory({ story, children }: { story: string; children?: ReactNode }) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const [clamped, setClamped] = useState(false)
@@ -26,7 +38,8 @@ export function ExpandableStory({ story }: { story: string }) {
// The text block toggles the same clamp as the Show more / Show less buttons
// below it. It only takes focus while there is something to toggle, so a
// short story stays plain text instead of an empty stop in the tab order.
const toggle: HTMLAttributes<HTMLDivElement> = clamped || expanded
const hasFold = clamped || !!children
const toggle: HTMLAttributes<HTMLDivElement> = hasFold || expanded
? {
role: 'button',
tabIndex: 0,
@@ -42,12 +55,13 @@ export function ExpandableStory({ story }: { story: string }) {
ref={ref}
{...toggle}
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
expanded ? '' : 'line-clamp-3 md:line-clamp-4'
} ${hasFold || expanded ? 'cursor-pointer' : ''}`}
>
<JournalBody text={story} />
</div>
{clamped && !expanded && (
{expanded && children}
{hasFold && !expanded && (
<button type="button"
onClick={() => setExpanded(true)}
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
@@ -221,10 +221,12 @@ export function ProviderPicker({
</div>
)}
{/* Filter bar */}
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
{/* Filter bar. Embedded in the entry editor this sits in a panel a few hundred
pixels tall, so its padding is the difference between three rows of
thumbnails and two. */}
<div className={`flex-shrink-0 border-b border-zinc-200 dark:border-zinc-700 ${embedded ? 'px-3 py-2' : 'px-6 py-3'}`}>
{/* Tabs */}
<div className="mb-3 flex gap-1.5">
<div className={`flex gap-1.5 ${embedded && filter === 'day' ? '' : 'mb-3'}`}>
{[
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
@@ -552,14 +554,11 @@ export function ProviderPicker({
)}
</div>
{/* Footer */}
<div className="flex flex-shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-800/50">
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-200/60 px-2.5 py-1 text-[11px] leading-none text-zinc-500 dark:bg-zinc-700/60 dark:text-zinc-400">
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] leading-none font-bold text-white dark:bg-white dark:text-zinc-900">
{selected.size}
</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</span>
{/* Footer. The count used to sit here as its own pill as well as on the Add
button; one of the two was always saying it twice, and inside the entry
editor the row it took cost the grid a row of thumbnails (discussion
#2299). Embedded, the whole bar is tighter for the same reason. */}
<div className={`flex flex-shrink-0 items-center justify-end border-t border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50 ${embedded ? 'px-3 py-2' : 'px-6 py-4'}`}>
<div className="flex items-center gap-2">
<button
type="button"
@@ -1,6 +1,6 @@
import { useState, useRef } from 'react'
import { useNavigate } from 'react-router'
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore } from 'lucide-react'
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore, Undo2 } from 'lucide-react'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi } from '../../api/client'
@@ -12,12 +12,14 @@ import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helper
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import { normalizeImageFile } from '../../utils/convertHeic'
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions }: {
journey: JourneyDetail
onClose: () => void
onSaved: () => void
onOpenInvite: () => void
onRefresh: () => void
/** Bring back every suggestion waved away card by card. Absent for a viewer. */
onRestoreSuggestions?: () => Promise<void> | void
}) {
const { t } = useTranslation()
const [title, setTitle] = useState(journey.title)
@@ -95,6 +97,30 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
}
}
/**
* Turn one of the optional entry fields off for this journey.
*
* Saved on the spot for the same reason the tracks switch is, and through
* `onRefresh` rather than `onSaved` so flipping a switch does not close the
* dialog out from under a half-typed title.
*
* Nothing is erased: an entry that already carries a mood keeps it in the
* database, the form simply stops asking. Switching back on brings it into
* view again.
*/
const [savingField, setSavingField] = useState<string | null>(null)
const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather') => {
setSavingField(field)
try {
await updateJourney(journey.id, { [field]: journey[field] === 0 })
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingField(null)
}
}
const handleDelete = async () => {
try {
await deleteJourney(journey.id)
@@ -183,6 +209,57 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</button>
</div>
{/* The three fields a journey may put away (discussion #2299) */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.entryFields')}</label>
<p className="text-[11px] leading-snug text-zinc-500 mb-2">{t('journey.settings.entryFieldsHint')}</p>
<div className="flex flex-col gap-1.5">
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => {
const on = journey[field] !== 0
return (
<button
key={field}
type="button"
role="switch"
aria-checked={on}
aria-label={label}
disabled={savingField !== null}
onClick={() => handleFieldToggle(field)}
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
>
<span className="flex-1 min-w-0 text-[14px] text-zinc-900 dark:text-white">{label}</span>
<span aria-hidden="true" className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${on ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}>
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${on ? 'translate-x-4' : ''}`} />
</span>
</button>
)
})}
</div>
</div>
{/* The way back from dismissing suggestions one at a time. Only ever shown
when there is something to bring back, so it is not a permanent row
about a feature most journeys never touch. */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => { void onRestoreSuggestions() }}
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
>
<Undo2 size={15} className="text-zinc-400 flex-shrink-0" />
<span className="flex-1 min-w-0">
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.suggestions.restore')}</span>
<span className="block text-[11px] text-zinc-500">
{t('journey.suggestions.restoreCount', { count: String(journey.dismissed_count) })}
</span>
</span>
</button>
)}
</div>
{/* Right column */}
@@ -0,0 +1,115 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-008
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { render, screen } from '../../../tests/helpers/render'
import JourneyEntryCover, { type CoverEntry } from './JourneyEntryCover'
function buildEntry(overrides: Partial<CoverEntry> = {}): CoverEntry {
return {
id: 10,
type: 'entry',
title: 'Mercado da Ribeira',
entry_date: '2026-03-15',
location_name: 'Lisbon, Portugal',
country_code: 'PT',
photos: [{ photo_id: 7 }],
...overrides,
}
}
describe('JourneyEntryCover', () => {
it('FE-JRN-COVER-001: the photo fills the card, with the name and place on it', () => {
// Queried by tag: the photo is decoration behind the words, so it carries an
// empty alt and no img role.
const { container } = render(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(screen.getByText('Mercado da Ribeira')).toBeInTheDocument()
expect(screen.getByText('Lisbon, Portugal')).toBeInTheDocument()
})
it('FE-JRN-COVER-002: an entry with no photo still reads as a card', () => {
// The day colour carries it, so the carousel does not go blank where somebody
// wrote something down without taking a picture.
const { container } = render(<JourneyEntryCover entry={buildEntry({ photos: [] })} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
// The country's flag is an <img> of its own, so the photo is queried by source.
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
expect(screen.getByText('Mercado da Ribeira')).toBeInTheDocument()
})
it('FE-JRN-COVER-003: the country rides in the corner, and only with a country', () => {
const { rerender } = render(
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(screen.getByAltText('PT')).toBeInTheDocument()
rerender(
<JourneyEntryCover entry={buildEntry({ country_code: null })} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(screen.queryByAltText('PT')).toBeNull()
})
it('FE-JRN-COVER-004: a suggestion says so, so it is not mistaken for somewhere you have been', () => {
render(
<JourneyEntryCover
entry={buildEntry({ type: 'skeleton', title: null })}
dayColor="#6366f1"
isActive={false}
onClick={() => {}}
/>,
)
// The corner mark says it to a screen reader; the title says it in words,
// since a suggestion pulled from a trip place is the one card with no story.
expect(screen.getByLabelText('Suggestion')).toBeInTheDocument()
expect(screen.getByText('Suggestion')).toBeInTheDocument()
})
it('FE-JRN-COVER-005: mood and weather appear only while the journey keeps those fields', () => {
const entry = buildEntry({ mood: 'good', weather: 'sunny' })
const { container, rerender } = render(
<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
const withBoth = container.querySelectorAll('svg').length
rerender(
<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} showMood={false} showWeather={false} />,
)
expect(container.querySelectorAll('svg').length).toBe(withBoth - 2)
})
it('FE-JRN-COVER-006: the active card is the larger one', () => {
const { container, rerender } = render(
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(container.querySelector('button')!.className).toContain('w-[136px]')
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
expect(container.querySelector('button')!.className).toContain('w-[164px]')
})
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
const { container } = render(
<JourneyEntryCover
entry={buildEntry()}
dayColor="#6366f1"
isActive={false}
onClick={() => {}}
photoUrlFor={id => `/api/public/journey/tok/photos/${id}/original`}
/>,
)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/public/journey/tok/photos/7/original')
})
it('FE-JRN-COVER-008: tapping it opens the entry', async () => {
const onClick = vi.fn()
render(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={onClick} />)
await userEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,177 @@
import { MapPin, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore'
/**
* One entry of the phone timeline: the photo IS the card.
*
* It used to be a 64px thumbnail beside three lines of text, which spent the
* card's width on a story nobody reads at that size and left the title truncated
* after a handful of characters (discussion #2299). Now the picture fills the
* card and the words sit on it: title and place bottom-left, where the eye lands,
* and the two facts that place the moment — which country, which day — in the
* corners above.
*
* The day is carried by the colour rather than by a number. A numbered card only
* answers "how many stops into this day am I", which is not a question anyone
* asks; the day scrubber above the carousel answers the one they do.
*
* Two shells draw this card. `tone` picks the surface a card without a photo
* falls back to, because the phone shell's tokens are scoped to `.m-root` and
* the tablet timeline is outside it. Everything else is a photo and white text,
* which needs no tokens at all.
*/
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
export interface CoverEntry {
id: number
type: JourneyEntry['type']
title?: string | null
story?: string | null
location_name?: string | null
location_lat?: number | null
location_lng?: number | null
country_code?: string | null
entry_date: string
entry_time?: string | null
mood?: string | null
weather?: string | null
photos?: CardPhoto[]
}
interface Props {
entry: CoverEntry | JourneyEntry
/** The colour of the day this entry belongs to, from `DAY_COLORS`. */
dayColor: string
isActive: boolean
onClick: () => void
/** Off when the journey has put that field away (journey settings). */
showMood?: boolean
showWeather?: boolean
/** The public journey page serves photos through a share token. */
photoUrlFor?: (photoId: number) => string
tone?: 'app' | 'mobile'
}
export default function JourneyEntryCover({
entry,
dayColor,
isActive,
onClick,
showMood = true,
showWeather = true,
photoUrlFor,
tone = 'app',
}: Props) {
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
const photoId = cardPhotoId(entry.photos?.[0])
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
const title = cardTitle(entry, t)
const place = cardPlace(entry)
const date = cardDateLabel(entry.entry_date, locale)
const mood = showMood ? moodMeta(entry.mood) : undefined
const weather = showWeather ? weatherMeta(entry.weather) : undefined
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
// One ground for every corner mark. Dark rather than a white veil: these sit on
// whatever photograph the traveller took, and a bright sky swallowed the white one.
const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]'
const badgeDot = 'flex h-[18px] w-[18px] items-center justify-center rounded-full bg-black/45 backdrop-blur-[2px]'
return (
<button
type="button"
onClick={onClick}
// The whole card is the photo, so its own ring is what separates it from the
// map behind. A suggestion is drawn as an outline rather than a solid thing:
// it is somewhere you planned to be, not somewhere you have written about.
className={`relative flex-none overflow-hidden rounded-[18px] text-left transition-[width,height] duration-150 ${
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
// the class supplies and leave the map showing through a card that is only a
// faint wash of the day's colour.
// The day is the card's own edge rather than a bar along its foot: a hairline
// all the way round groups a day at a glance without taking a strip of the
// photograph. The active card gets a heavier one of the same colour.
//
// Inset, not an outer ring: the carousel scrolls, so it clips, and an outer
// ring lost its top edge to that clip.
style={{
boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`,
...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
}}
>
{src ? (
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
) : (
<span className="absolute inset-0 flex items-center justify-center">
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
</span>
)}
{/* Two washes rather than one: the top corners carry small marks that would
otherwise sit on a bright sky, and the bottom has to hold two lines of text. */}
<span className="pointer-events-none absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/45 to-transparent" />
<span className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/80 via-black/45 to-transparent" />
{/* Just the flag: it is already a coloured mark of its own, and a chip behind
it only added a second shape to read. A drop shadow does the work the chip
was doing, against a bright sky. Sized and placed to sit on the same line
as the date opposite it. */}
{entry.country_code && (
<span className="absolute left-2 top-2 flex h-[18px] items-center drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
<CountryFlag code={entry.country_code} size={14} />
</span>
)}
{/* A mark rather than the word: "Suggestion" spelled out took a third of a
136px card and pushed the date off it. The plus is the same promise the
desktop suggestion card makes with its Add Entry button, and the card's
italic, dimmed title carries the rest. */}
{isSuggestion && (
<span
className={`absolute top-2 ${entry.country_code ? 'left-[30px]' : 'left-2'} ${badgeDot} text-white`}
title={t('journey.entry.suggestion')}
aria-label={t('journey.entry.suggestion')}
>
<Plus size={12} strokeWidth={2.8} />
</span>
)}
{/* Top right: when, and how it was. They fit beside each other now that the
flag on the left is a bare mark rather than a chip of its own. */}
<span className="absolute right-2 top-2 flex items-center gap-1">
{mood && (
<span className={badgeDot} style={{ color: mood.color }}>
<mood.icon size={11} strokeWidth={2.4} />
</span>
)}
{weather && (
<span className={`${badgeDot} text-white`}>
<weather.icon size={11} strokeWidth={2.4} />
</span>
)}
<span className={badge}>{date}</span>
</span>
{/* Bottom: the name gets the room the thumbnail layout never had. */}
<span className="absolute inset-x-0 bottom-0 flex flex-col gap-[1px] px-[10px] pb-[10px]">
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold text-white drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
{title}
</span>
{place && (
<span className="truncate text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
{place}
</span>
)}
</span>
</button>
)
}
@@ -26,6 +26,7 @@ vi.mock('leaflet', () => {
fitBounds: vi.fn(),
setView: vi.fn(),
flyTo: vi.fn(),
panTo: vi.fn(),
getZoom: vi.fn(() => 10),
// Leaflet throws "Set map center and zoom first." out of these until the map
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
@@ -47,6 +48,9 @@ vi.mock('leaflet', () => {
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
layerGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), remove: vi.fn() })),
// The map builds its own attribution control so it can collapse to the credit
// alone (see the GL twin: the phone lays a carousel over the bottom edge).
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
},
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })),
@@ -54,6 +58,7 @@ vi.mock('leaflet', () => {
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
};
});
@@ -62,6 +67,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories';
import { AMAP_ROAD } from '../../constants/mapDefaults';
import type { Mock } from 'vitest';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
@@ -89,6 +95,11 @@ vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These
// cases are about the GL layer, so the probe says yes here.
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} }));
// The real CRS is built from Leaflet's projection, which the mock above does not
// carry. What matters here is only that the map is handed one for Amap tiles.
vi.mock('../Map/gcj02Crs', () => ({
crsForBasemap: (gcj02: boolean) => (gcj02 ? { code: 'TREK:GCJ02' } : undefined),
}));
const entriesWithCoords = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
@@ -154,10 +165,11 @@ describe('JourneyMap', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// Each marker calls bindTooltip with the entry label
// The tooltip is the same card the GL renderer draws now (#2299), so the title
// is inside its markup rather than being the whole label.
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
'Paris',
expect.stringContaining('Paris'),
expect.objectContaining({ direction: 'top' }),
);
});
@@ -198,12 +210,12 @@ describe('JourneyMap', () => {
// Tooltips use the entry titles
const mockMarker1 = (L.marker as any).mock.results[0].value;
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
'Happy Paris',
expect.stringContaining('Happy Paris'),
expect.objectContaining({ direction: 'top' }),
);
const mockMarker2 = (L.marker as any).mock.results[1].value;
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
'Sad Berlin',
expect.stringContaining('Sad Berlin'),
expect.objectContaining({ direction: 'top' }),
);
});
@@ -310,20 +322,22 @@ describe('JourneyMap', () => {
expect(vi.mocked(L.divIcon).mock.calls.length).toBe(iconsBefore);
});
it('FE-COMP-JOURNEYMAP-017: focusMarker flies to the pin, never below zoom 12', () => {
it('FE-COMP-JOURNEYMAP-017: focusMarker pans to the pin and leaves the zoom where the reader put it', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
act(() => { ref.current!.focusMarker('e2'); });
// getZoom() is stubbed at 10, so the floor of 12 wins
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
// It used to force a floor of zoom 12, which yanked a country view to street
// level on the first scroll through the timeline (discussion #2299).
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
expect(mockedMap().flyTo).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-018: focusMarker swallows leaflet errors when the map has no view yet', () => {
const ref = React.createRef<JourneyMapHandle>();
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
expect(() => act(() => { ref.current!.focusMarker('e1'); })).not.toThrow();
expect(mockedMap().flyTo).not.toHaveBeenCalled();
@@ -420,6 +434,18 @@ describe('JourneyMap', () => {
seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: 'https://tiles.test/{z}/{x}/{y}.png' }) });
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe('https://tiles.test/{z}/{x}/{y}.png');
// A WGS-84 template leaves the projection alone: passing the default CRS
// explicitly would be the same value, but omitting it keeps the map as it was.
expect(vi.mocked(L.map).mock.calls[0][1]).not.toHaveProperty('crs');
});
it('FE-COMP-JOURNEYMAP-027b: an Amap preset builds the map on the GCJ-02 projection', () => {
// The same shift the planner map applies. Miss it here and every entry on
// the journey page sits a few hundred metres from where the planner drew it.
seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: AMAP_ROAD }) });
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe(AMAP_ROAD);
expect(vi.mocked(L.map).mock.calls[0][1]).toMatchObject({ crs: { code: 'TREK:GCJ02' } });
});
it('FE-COMP-JOURNEYMAP-041: a basemap change restyles in place instead of rebuilding the map (#2097)', async () => {
@@ -444,13 +470,13 @@ describe('JourneyMap', () => {
expect(layer.getMaplibreMap().setStyle).not.toHaveBeenCalledWith(expect.stringContaining('tiles.test'));
});
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop flies to that marker after the settle delay', () => {
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop pans to that marker after the settle delay', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e2" />);
expect(mockedMap().flyTo).not.toHaveBeenCalled();
expect(mockedMap().panTo).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(60); });
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
} finally {
vi.useRealTimers();
}
@@ -460,8 +486,9 @@ describe('JourneyMap', () => {
vi.useFakeTimers();
try {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />);
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
act(() => { vi.advanceTimersByTime(60); });
// The catch is where a map with no view at all gets its first one.
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
expect(mockedMap().flyTo).not.toHaveBeenCalled();
} finally {
@@ -529,7 +556,9 @@ describe('JourneyMap', () => {
]}
/>
);
const tooltipTitles = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
const titleOf = (html: unknown) =>
String(html).match(/trek-journey-popup-title">([^<]*)</)?.[1] ?? '';
const tooltipTitles = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => titleOf(c[0]));
expect(tooltipTitles).toEqual(['Paris', 'Berlin']);
});
@@ -540,7 +569,10 @@ describe('JourneyMap', () => {
entries={[{ id: 'e0', lat: 1, lng: 2, title: null, mood: null, entry_date: '2025-06-01' }]}
/>
);
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith('Entry', expect.objectContaining({ direction: 'top' }));
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith(
expect.stringContaining('Entry'),
expect.objectContaining({ direction: 'top' }),
);
});
it('FE-COMP-JOURNEYMAP-037: unmounting tears the leaflet map down', () => {
@@ -668,7 +700,7 @@ describe('JourneyMap', () => {
const labels = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
expect(labels.some(l => typeof l === 'string' && l.includes('<img'))).toBe(false);
expect(labels).toContain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
expect(labels.some(l => typeof l === 'string' && l.includes('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;'))).toBe(true);
});
// #1614 — photos placed by their own capture coordinates, collapsed by proximity.
it('FE-COMP-JOURNEYMAP-046: draws one thumbnail per cluster and counts the rest', () => {
+72 -16
View File
@@ -2,20 +2,24 @@ import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'r
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
import { isGcj02Basemap, isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
import { crsForBasemap } from '../Map/gcj02Crs'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
export interface MapMarkerItem {
id: string
lat: number
lng: number
label: string
locationName: string
mood?: string | null
time: string
dayColor: string
dayLabel: number
photoUrls: string[]
}
/**
@@ -76,10 +80,13 @@ interface MapEntry {
lat: number
lng: number
title?: string | null
location_name?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
photoUrls?: string[]
}
interface Props {
@@ -97,6 +104,16 @@ interface Props {
activeMarkerId?: string | null
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
/**
* Leave the marker labels off.
*
* On the phone the map sits above a carousel whose active card already carries
* the entry's name, and a tooltip on touch is a tap-to-open box rather than a
* hover hint — so it says the same thing twice and covers the map to do it
* (discussion #2299). On desktop the label is the only name a marker has, so
* this stays off there.
*/
hideMarkerTooltip?: boolean
paddingBottom?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string
@@ -111,10 +128,12 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
lat: e.lat,
lng: e.lng,
label: e.title || 'Entry',
locationName: e.location_name || '',
mood: e.mood,
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
photoUrls: e.photoUrls ?? [],
})
}
}
@@ -148,14 +167,22 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props,
) {
// Read through a ref: the flag is fixed per surface, and putting it in the
// marker effect's deps would rebuild every marker for nothing.
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
const storedCartoKey = useCartoApiKey()
const cartoKey = cartoApiKey || storedCartoKey
const tileUrl = resolveTileUrl(mapTileUrl, dark ? OFM_DARK : OFM_POSITRON, cartoKey)
// Amap's tiles are GCJ-02 (see gcj02Crs.ts), the same shift the planner map
// applies. Leaflet fixes a map's CRS at construction, so this one value is
// allowed to rebuild the map where a template change only retiles it.
const isGcjBasemap = !isVectorStyle(tileUrl) && isGcj02Basemap(tileUrl)
// Read through a ref by the map effect, retiled in place by its own effect below:
// the CARTO key reaches the store after the first render, and rebuilding the map
// for that raced with the markers and layers already on it (#2097).
@@ -214,12 +241,22 @@ function JourneyMap(
}
}, [])
/**
* Bring an entry's marker under the reader without changing how far out they are.
*
* This fires on every step through the timeline, and it used to force zoom 12.
* Reading a journey from a country view therefore yanked the map to street level
* on the first scroll and kept it there: every marker filled the screen, and the
* one thing a map is for — where is this, relative to everything else — was gone
* (discussion #2299). Panning keeps the frame the reader chose; the initial
* fitBounds is what decides how close the journey starts out.
*/
const focusMarker = useCallback((id: string) => {
highlightMarker(id)
const marker = markersRef.current.get(id)
if (marker && mapRef.current) {
try {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
} catch { /* map not yet initialized */ }
}
}, [])
@@ -235,13 +272,18 @@ function JourneyMap(
markersRef.current.clear()
const crs = crsForBasemap(isGcjBasemap)
const map = L.map(containerRef.current, {
...(crs ? { crs } : {}),
zoomControl: false,
attributionControl: true,
// Added below with `prefix: false` so it collapses to the credit alone; see
// the GL twin for why it is not a strip of text across the bottom.
attributionControl: false,
scrollWheelZoom: fullScreen ? true : false,
dragging: true,
touchZoom: true,
})
L.control.attribution({ position: 'bottomright', prefix: false }).addTo(map)
mapRef.current = map
cancelledRef.current = false
@@ -339,13 +381,27 @@ function JourneyMap(
})
const marker = L.marker(pos, { icon }).addTo(map)
// Escaped for the same reason as the track tooltip above: the label is an
// entry title, and this map is what the public journey page renders.
marker.bindTooltip(escapeHtml(item.label), {
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip',
})
// The same card the GL renderer shows, from the same builder: which map
// engine a reader happens to have selected should not change what a marker
// tells them (discussion #2299). The builder escapes everything that came
// from a person, which matters most here — this map is what the public
// journey page renders.
if (!hideMarkerTooltipRef.current) {
ensureJourneyPopupStyle()
marker.bindTooltip(
journeyPopupHtml({
title: item.label || item.locationName || 'Entry',
place: item.label ? item.locationName : '',
date: formatMarkerDate(item.time),
photoUrls: item.photoUrls,
}),
{
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip trek-journey-tooltip',
},
)
}
marker.on('click', () => {
onMarkerClickRef.current?.(item.id)
@@ -384,7 +440,7 @@ function JourneyMap(
glLayerRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
// Retile in place rather than through the effect above, which would drop every
// marker and track it just drew. A vector basemap restyles instead, which also
@@ -455,11 +511,11 @@ function JourneyMap(
highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId)
if (!marker || !mapRef.current) return
// fitBounds may still be pending when this fires — getZoom() throws
// "Set map center and zoom first" until the map has a view. Guard it.
// Pan, don't zoom — see focusMarker. fitBounds may still be pending when this
// fires, and panTo on a map with no view throws "Set map center and zoom
// first", so the catch is where the map gets its first one.
try {
const currentZoom = mapRef.current.getZoom()
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
} catch {
mapRef.current.setView(marker.getLatLng(), 12)
}
@@ -34,6 +34,9 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
hideMarkerTooltip?: boolean
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
}
function JourneyMapAuto({ ref, ...props }: Props) {
@@ -35,6 +35,7 @@ const gl = vi.hoisted(() => {
remove: vi.fn(),
resize: vi.fn(),
flyTo: vi.fn(),
easeTo: vi.fn(),
fitBounds: vi.fn(),
getZoom: vi.fn(() => 10),
addSource: vi.fn(),
@@ -194,7 +195,8 @@ describe('JourneyMapGL', () => {
render(<JourneyMapGL checkins={[]} entries={entries} />)
expect(mapboxgl.accessToken).toBe('pk.test_token')
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
expect(mapOptions()).toMatchObject({ style: 'mapbox://styles/mapbox/standard', attributionControl: true })
// Collapsed to its ⓘ rather than a strip of text across the bottom (#2299).
expect(mapOptions()).toMatchObject({ style: 'mapbox://styles/mapbox/standard', attributionControl: { compact: true } })
})
it('FE-COMP-JMAPGL-003: maplibre needs no token and opts out of the around-center rotate', () => {
@@ -330,7 +332,7 @@ describe('JourneyMapGL', () => {
const html = popupHtml()
expect(html).toContain('>Louvre<')
expect(html).toContain('trek-journey-popup-place')
expect(html).toContain('trek-journey-popup-sep')
expect(html).toContain('trek-journey-popup-chip')
})
it('FE-COMP-JMAPGL-014: the popup falls back to the location name when the entry has no title', () => {
@@ -348,9 +350,9 @@ describe('JourneyMapGL', () => {
const html = popupHtml()
expect(html).toContain('>Reykjavík<')
// the location moved to the title line, so there is no place chip left below
// the location moved to the title line, so there is no place chip left below.
// The date keeps its own chip, which is why only the place one is checked.
expect(html).not.toContain('trek-journey-popup-place')
expect(html).not.toContain('trek-journey-popup-sep')
})
it('FE-COMP-JMAPGL-015: an entry with neither title nor place still gets a heading', () => {
@@ -460,19 +462,21 @@ describe('JourneyMapGL', () => {
expect(document.querySelectorAll('#trek-journey-popup-style')).toHaveLength(1)
})
it('FE-COMP-JMAPGL-023: focusMarker flies in with the 3D pitch and a zoom floor of 14', () => {
it('FE-COMP-JMAPGL-023: focusMarker eases over with the 3D pitch and no zoom of its own', () => {
withToken()
const ref = React.createRef<JourneyMapGLHandle>()
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
act(() => { ref.current!.focusMarker('e2') })
expect(gl.map.flyTo).toHaveBeenCalledWith({
// No zoom in the call at all: stepping through the timeline keeps the frame the
// reader chose (discussion #2299), same as the Leaflet twin.
expect(gl.map.easeTo).toHaveBeenCalledWith({
center: [13.405, 52.52],
zoom: 14,
pitch: 45,
duration: 600,
})
expect(gl.map.flyTo).not.toHaveBeenCalled()
})
it('FE-COMP-JMAPGL-024: with 3D disabled the camera stays flat', () => {
@@ -482,7 +486,7 @@ describe('JourneyMapGL', () => {
act(() => { ref.current!.focusMarker('e1') })
expect(gl.map.flyTo).toHaveBeenCalledWith(expect.objectContaining({ pitch: 0 }))
expect(gl.map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ pitch: 0 }))
expect(addCustom3dBuildings).not.toHaveBeenCalled()
})
@@ -490,7 +494,7 @@ describe('JourneyMapGL', () => {
withToken()
const ref = React.createRef<JourneyMapGLHandle>()
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
gl.map.getZoom.mockImplementationOnce(() => { throw new Error('not ready') })
gl.map.easeTo.mockImplementationOnce(() => { throw new Error('not ready') })
expect(() => act(() => { ref.current!.focusMarker('e1') })).not.toThrow()
expect(gl.map.flyTo).not.toHaveBeenCalled()
@@ -512,19 +516,18 @@ describe('JourneyMapGL', () => {
expect(gl.map.resize).toHaveBeenCalled()
})
it('FE-COMP-JMAPGL-028: the activeMarkerId prop highlights and flies after the settle delay', () => {
it('FE-COMP-JMAPGL-028: the activeMarkerId prop highlights and eases over after the settle delay', () => {
vi.useFakeTimers()
try {
withToken()
render(<JourneyMapGL checkins={[]} entries={entries} activeMarkerId="e2" />)
expect(gl.map.flyTo).not.toHaveBeenCalled()
expect(gl.map.easeTo).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(60) })
expect(gl.popups).toHaveLength(1)
expect(gl.map.flyTo).toHaveBeenCalledWith(expect.objectContaining({
expect(gl.map.easeTo).toHaveBeenCalledWith(expect.objectContaining({
center: [13.405, 52.52],
zoom: 12,
duration: 500,
}))
} finally {
+54 -128
View File
@@ -4,6 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -21,6 +22,8 @@ interface MapEntry {
entry_date: string
dayColor?: string
dayLabel?: number
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
photoUrls?: string[]
}
interface Props {
@@ -35,6 +38,15 @@ interface Props {
activeMarkerId?: string | null
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
/** See the Leaflet twin: no marker labels where a carousel already names the entry. */
hideMarkerTooltip?: boolean
/**
* Open the entry's photos from the card's thumbnail strip.
*
* Wiring it is what makes the strip take the pointer at all; without it the
* card stays the inert label it has always been.
*/
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
paddingBottom?: number
glProvider?: GlMapProvider
/**
@@ -55,6 +67,7 @@ interface Item {
time: string
dayColor: string
dayLabel: number
photoUrls: string[]
}
const MARKER_W = 28
@@ -73,6 +86,7 @@ function buildItems(entries: MapEntry[]): Item[] {
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
photoUrls: e.photoUrls ?? [],
})
}
}
@@ -80,109 +94,6 @@ function buildItems(entries: MapEntry[]): Item[] {
return items
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function formatEntryDate(iso: string): string {
if (!iso) return ''
try {
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
if (Number.isNaN(d.getTime())) return iso
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
} catch {
return iso
}
}
// Inject the popup styles once per document. Two-line frosted-glass card in
// the Apple/Google Maps idiom — title on top, location / date subtly below.
function ensureJourneyPopupStyle() {
if (document.getElementById('trek-journey-popup-style')) return
const s = document.createElement('style')
s.id = 'trek-journey-popup-style'
s.textContent = `
.mapboxgl-popup.trek-journey-popup,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
font-family:var(--font-system);
min-width: 160px;
max-width: 280px;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA;
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94);
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: #18181B;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub {
display: flex;
align-items: baseline;
gap: 7px;
margin-top: 3px;
font-size: 11.5px;
color: #71717A;
line-height: 1.35;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.trek-journey-popup-sep {
flex: 0 0 auto;
opacity: 0.55;
font-weight: 500;
}
.trek-journey-popup-date { flex: 0 0 auto; }
@keyframes trek-journey-popup-in {
from { opacity: 0; }
to { opacity: 1; }
}
`
document.head.appendChild(s)
}
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
const fill = dayColor
const textColor = '#fff'
@@ -217,8 +128,14 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, ref }: Props,
) {
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
onMarkerPhotoClickRef.current = onMarkerPhotoClick
/** Which entry the card is currently describing, for the delegated photo click. */
const popupItemIdRef = useRef<string | null>(null)
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
@@ -247,38 +164,35 @@ function JourneyMapGL(
mapLangRef.current = mapLang
const showPopup = useCallback((id: string) => {
// See `hideMarkerTooltip` on the Leaflet twin: below the carousel the card
// already says this, and the popup only covers the map to repeat it.
if (hideMarkerTooltipRef.current) return
const item = itemsRef.current.find(i => i.id === id)
if (!item || !mapRef.current) return
ensureJourneyPopupStyle()
// Primary line: user-given title. If none, fall back to the location
// name so we always show *something* useful on the top line.
const primaryRaw = item.label || item.locationName || 'Entry'
const secondaryPlace = item.label ? item.locationName : ''
const dateStr = formatEntryDate(item.time)
const primary = escapeHtml(primaryRaw)
const place = escapeHtml(secondaryPlace)
const date = escapeHtml(dateStr)
const html = journeyPopupHtml({
title: item.label || item.locationName || 'Entry',
place: item.label ? item.locationName : '',
date: formatMarkerDate(item.time),
photoUrls: item.photoUrls,
})
const subParts: string[] = []
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
const subline = subParts.length === 2
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
: subParts.join('')
const html = `
<div class="trek-journey-popup-title">${primary}</div>
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
`
// Marker is bottom-anchored with a visible height of 36px (1.2× on
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
const offset: [number, number] = [0, -46]
popupItemIdRef.current = item.id
const interactive = !!onMarkerPhotoClickRef.current && item.photoUrls.length > 0
if (popupRef.current) {
popupRef.current.setLngLat([item.lng, item.lat])
popupRef.current.setHTML(html)
popupRef.current.setOffset(offset)
const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
if (el) {
el.classList.toggle('trek-dark', !!darkRef.current)
el.classList.toggle('is-interactive', interactive)
}
} else {
popupRef.current = new gl.Popup({
closeButton: false,
@@ -286,12 +200,22 @@ function JourneyMapGL(
closeOnMove: false,
anchor: 'bottom',
offset,
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}${interactive ? ' is-interactive' : ''}`,
maxWidth: '280px',
})
.setLngLat([item.lng, item.lat])
.setHTML(html)
.addTo(mapRef.current)
// Delegated, and bound once for the popup's whole life: setHTML replaces the
// strip on every entry, so a listener on the thumbnails themselves would have
// to be rebound each time and would leak the ones it forgot.
popupRef.current.getElement()?.addEventListener('click', (ev: Event) => {
const shot = (ev.target as HTMLElement | null)?.closest(`[${SHOT_INDEX_ATTR}]`)
const entryId = popupItemIdRef.current
if (!shot || !entryId) return
ev.stopPropagation()
onMarkerPhotoClickRef.current?.(entryId, Number(shot.getAttribute(SHOT_INDEX_ATTR)))
})
}
}, [gl])
@@ -330,14 +254,14 @@ function JourneyMapGL(
}
}, [setMarkerStyle, showPopup, hidePopup])
/** Pan to the marker and leave the zoom alone — see the Leaflet twin for why. */
const focusMarker = useCallback((id: string) => {
highlightMarker(id)
const marker = markersRef.current.get(id)
if (!marker || !mapRef.current) return
try {
mapRef.current.flyTo({
mapRef.current.easeTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0,
duration: 600,
})
@@ -370,7 +294,10 @@ function JourneyMapGL(
center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
attributionControl: true,
// Collapsed to its ⓘ button rather than a strip of text: the phone lays a card
// carousel across the bottom of this map and the strip read through it. The
// credit stays one tap away, which is what the OSM licence asks for.
attributionControl: { compact: true },
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
@@ -516,9 +443,8 @@ function JourneyMapGL(
const marker = markersRef.current.get(activeMarkerId)
if (!marker || !mapRef.current) return
try {
mapRef.current.flyTo({
mapRef.current.easeTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 500,
})
@@ -1,183 +0,0 @@
// FE-COMP-JENTRYCARD-001 to FE-COMP-JENTRYCARD-014
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import MobileEntryCard from './MobileEntryCard'
type CardEntry = Parameters<typeof MobileEntryCard>[0]['entry']
function buildEntry(overrides: Partial<Record<string, unknown>> = {}): CardEntry {
return {
id: 1,
type: 'entry',
title: 'Sunrise over the fjord',
location_name: 'Geiranger, Møre og Romsdal, Norway',
location_lat: 62.1,
location_lng: 7.2,
entry_date: '2025-06-01',
entry_time: '07:30:00',
mood: null,
weather: null,
photos: [],
story: null,
...overrides,
} as unknown as CardEntry
}
function renderCard(entry: CardEntry, props: Partial<{ isActive: boolean; onClick: () => void; publicPhotoUrl: (id: number) => string; dayLabel: number; dayColor: string }> = {}) {
const onClick = props.onClick ?? vi.fn()
const result = render(
<MobileEntryCard
entry={entry}
dayLabel={props.dayLabel ?? 3}
dayColor={props.dayColor ?? '#ff0055'}
isActive={props.isActive ?? false}
onClick={onClick}
publicPhotoUrl={props.publicPhotoUrl}
/>,
)
return { ...result, onClick }
}
beforeEach(() => {
resetAllStores()
})
describe('MobileEntryCard', () => {
it('FE-COMP-JENTRYCARD-001: shows the day badge, date and time', () => {
renderCard(buildEntry())
const badge = screen.getByText('3')
expect(badge).toHaveStyle({ background: '#ff0055' })
expect(screen.getByText('Jun 1')).toBeInTheDocument()
// seconds are trimmed off the stored time
expect(screen.getByText('· 07:30')).toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-002: an entry without a time shows only the date', () => {
renderCard(buildEntry({ entry_time: null }))
expect(screen.queryByText(/·/)).not.toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-003: renders the first photo thumbnail from the api', () => {
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 42 }, { photo_id: 43 }] }))
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/42/thumbnail')
})
it('FE-COMP-JENTRYCARD-003b: a photo carrying only an id also resolves against the api', () => {
const { container } = renderCard(buildEntry({ photos: [{ id: 11 }] }))
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/11/thumbnail')
})
it('FE-COMP-JENTRYCARD-004: a public journey builds its own photo url', () => {
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 7 }] }), { publicPhotoUrl })
expect(container.querySelector('img')).toHaveAttribute('src', '/public/p/7.jpg')
})
it('FE-COMP-JENTRYCARD-004b: a shared-journey photo carrying only an id still resolves', () => {
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
const { container } = renderCard(buildEntry({ photos: [{ id: 11 }] }), { publicPhotoUrl })
expect(container.querySelector('img')).toHaveAttribute('src', '/public/p/11.jpg')
})
it('FE-COMP-JENTRYCARD-005: several photos add a counter badge', () => {
const { container } = renderCard(
buildEntry({ photos: [{ photo_id: 1 }, { photo_id: 2 }, { photo_id: 3 }] }),
{ dayLabel: 9 },
)
expect(container.querySelector('svg.lucide-camera')).not.toBeNull()
expect(screen.getByText('3')).toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-006: a single photo has no counter badge', () => {
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 1 }] }))
expect(container.querySelector('svg.lucide-camera')).toBeNull()
})
it('FE-COMP-JENTRYCARD-007: without photos it falls back to a pin placeholder', () => {
const { container } = renderCard(buildEntry())
expect(container.querySelector('img')).toBeNull()
expect(container.querySelectorAll('svg.lucide-map-pin').length).toBeGreaterThan(0)
})
it('FE-COMP-JENTRYCARD-008: mood and weather render their own icons', () => {
const { container } = renderCard(buildEntry({ mood: 'amazing', weather: 'rainy' }))
expect(container.querySelector('svg.lucide-laugh')).not.toBeNull()
expect(container.querySelector('svg.lucide-cloud-rain')).not.toBeNull()
})
it('FE-COMP-JENTRYCARD-008b: every known mood gets its own chip colour', () => {
const cases: [string, string, string][] = [
['good', 'lucide-smile', 'bg-amber-100'],
['neutral', 'lucide-meh', 'bg-zinc-100'],
['rough', 'lucide-frown', 'bg-violet-100'],
]
for (const [mood, icon, bg] of cases) {
const { container, unmount } = renderCard(buildEntry({ mood }))
const chip = container.querySelector(`svg.${icon}`)!.parentElement!
expect(chip.className).toContain(bg)
unmount()
}
})
it('FE-COMP-JENTRYCARD-009: an unknown mood renders no mood icon', () => {
const { container } = renderCard(buildEntry({ mood: 'ecstatic' }))
expect(container.querySelector('svg.lucide-laugh')).toBeNull()
expect(container.querySelector('svg.lucide-smile')).toBeNull()
})
it('FE-COMP-JENTRYCARD-010: untitled entries fall back to a type-specific label', () => {
const { unmount } = renderCard(buildEntry({ title: null, type: 'checkin' }))
expect(screen.getByText('Check-in')).toBeInTheDocument()
unmount()
const skeleton = renderCard(buildEntry({ title: null, type: 'skeleton' }))
expect(screen.getByText('Add your story…')).toBeInTheDocument()
skeleton.unmount()
renderCard(buildEntry({ title: '', type: 'entry' }))
expect(screen.getByText('Untitled')).toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-011: the story preview only appears on the active card, stripped of markdown', () => {
const story = '## Day one\n\nWe **walked** to the pier'
const inactive = renderCard(buildEntry({ story }))
expect(screen.queryByText(/We walked to the pier/)).not.toBeInTheDocument()
inactive.unmount()
renderCard(buildEntry({ story }), { isActive: true })
expect(screen.getByText('Day one We walked to the pier')).toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-012: the location badge shortens a long location name', () => {
renderCard(buildEntry())
// formatLocationName collapses the 3+ part name
expect(screen.getByText('Geiranger, Møre og Romsdal, Norway')).toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-013: a located entry without a name still says it is on the map', () => {
renderCard(buildEntry({ location_name: null }))
expect(screen.getByText('On the map')).toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-014: an entry without coordinates is marked as unlocated', () => {
renderCard(buildEntry({ location_lat: null, location_lng: null }))
expect(screen.getByText('No location')).toBeInTheDocument()
})
it('FE-COMP-JENTRYCARD-015: tapping the card calls back', () => {
const onClick = vi.fn()
renderCard(buildEntry(), { onClick })
fireEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('FE-COMP-JENTRYCARD-016: the active card grows', () => {
const inactive = renderCard(buildEntry())
expect(screen.getByRole('button').className).toContain('w-[240px]')
inactive.unmount()
renderCard(buildEntry(), { isActive: true })
expect(screen.getByRole('button').className).toContain('w-[320px]')
})
})
@@ -1,163 +0,0 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = {
amazing: Laugh,
good: Smile,
neutral: Meh,
rough: Frown,
}
const MOOD_COLORS: Record<string, string> = {
amazing: 'text-pink-500',
good: 'text-amber-500',
neutral: 'text-zinc-400',
rough: 'text-violet-500',
}
const WEATHER_ICONS: Record<string, typeof Sun> = {
sunny: Sun,
partly: CloudSun,
cloudy: Cloud,
rainy: CloudRain,
stormy: CloudLightning,
cold: Snowflake,
}
// Shared journeys hand us photos that only carry `id`, the journey's own
// photos carry `photo_id` — accept either.
function photoId(p: { photo_id?: number; id?: number }): number | undefined {
return p.photo_id ?? p.id
}
function photoUrl(id: number): string {
return `/api/photos/${id}/thumbnail`
}
function stripMarkdown(text: string): string {
return text
.replace(/[#*_~`>\[\]()!|-]/g, '')
.replace(/\n+/g, ' ')
.trim()
}
interface Props {
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
dayLabel: number
dayColor: string
isActive: boolean
onClick: () => void
publicPhotoUrl?: (photoId: number) => string
}
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
const firstPhotoId = firstPhoto ? photoId(firstPhoto) : undefined
const thumbSrc = firstPhotoId == null
? null
: publicPhotoUrl
? publicPhotoUrl(firstPhotoId)
: photoUrl(firstPhotoId)
const date = new Date(entry.entry_date + 'T00:00:00')
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
return (
<button type="button"
onClick={onClick}
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
isActive
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
} backdrop-blur-lg`}
>
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
{/* Photo thumbnail */}
{thumbSrc ? (
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
<img
src={thumbSrc}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
{hasPhotos && entry.photos!.length > 1 && (
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
<Camera size={10} />
{entry.photos!.length}
</div>
)}
</div>
) : (
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
</div>
)}
{/* Content */}
<div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
{dayLabel}
</span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && (
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
)}
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
{MoodIcon && (
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
'bg-zinc-100 dark:bg-zinc-700'
}`}>
<MoodIcon size={11} className={moodColor} />
</span>
)}
{WeatherIcon && (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
</span>
)}
</div>
</div>
{/* Title */}
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
</h4>
{/* Story preview (1-2 lines, only on active card) */}
{isActive && storyPreview && (
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
{storyPreview}
</p>
)}
{/* Location badge */}
<div className="flex items-center gap-1 mt-auto">
{hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
</span>
) : (
<span className="text-[10px] text-zinc-400 italic">No location</span>
)}
</div>
</div>
</div>
</button>
)
}
@@ -24,6 +24,7 @@ vi.mock('./JourneyMap', async () => {
}
})
import userEvent from '@testing-library/user-event'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import MobileMapTimeline from './MobileMapTimeline'
@@ -119,24 +120,37 @@ describe('MobileMapTimeline', () => {
it('FE-COMP-JMAPTL-001: renders one card per entry on top of a full-screen map', () => {
renderTimeline()
expect(screen.getByTestId('journey-map')).toBeInTheDocument()
expect(lastMapProps.current).toMatchObject({ height: 9999, fullScreen: true, paddingBottom: 200 })
// The strip below the map grew with the cover cards and the day bar (#2299), so
// the map keeps that much clear when it frames the journey.
expect(lastMapProps.current).toMatchObject({ height: 9999, fullScreen: true, paddingBottom: 250 })
expect(screen.getByText('Louvre')).toBeInTheDocument()
expect(screen.getByText('Museumsinsel')).toBeInTheDocument()
expect(screen.getByText('Reichstag')).toBeInTheDocument()
})
it('FE-COMP-JMAPTL-002: entries on the same day share a colour and count up', () => {
it('FE-COMP-JMAPTL-002: entries carry their day as a colour rather than as a number', () => {
// The number used to say which stop of the day this was, which is not a
// question anybody asks; the day bar answers the one they do (#2299). What
// survives is the colour, on a hairline at the foot of each card.
const { container } = renderTimeline()
const { cards } = carouselOf(container)
const badge = (card: HTMLElement) => card.querySelector('span[style]') as HTMLElement
const ring = (card: HTMLElement) => (card.querySelector('button') as HTMLElement).style.boxShadow
expect(badge(cards[0]).textContent).toBe('1')
expect(badge(cards[0])).toHaveStyle({ background: DAY_COLORS[0] })
// second day, first and second entry of that day
expect(badge(cards[1]).textContent).toBe('1')
expect(badge(cards[2]).textContent).toBe('2')
expect(badge(cards[1])).toHaveStyle({ background: DAY_COLORS[1] })
expect(badge(cards[2])).toHaveStyle({ background: DAY_COLORS[1] })
expect(ring(cards[0])).toContain(DAY_COLORS[0])
expect(ring(cards[1])).toContain(DAY_COLORS[1])
expect(ring(cards[2])).toContain(DAY_COLORS[1])
})
it('FE-COMP-JMAPTL-002b: the day bar offers one segment per day and jumps to it', async () => {
const { container } = renderTimeline()
const segments = screen.getAllByRole('button').filter(b => b.getAttribute('aria-label')?.startsWith('Jump to'))
expect(segments).toHaveLength(2)
await userEvent.click(segments[1])
const { cards } = carouselOf(container)
expect(cards[1].querySelector('button')!.className).toContain('w-[164px]')
})
it('FE-COMP-JMAPTL-003: the first entry starts out active and drives the map highlight', () => {
@@ -1,10 +1,11 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard'
import JourneyEntryCover from './JourneyEntryCover'
import JourneyDayScrubber from './JourneyDayScrubber'
import { dayColorOf, journeyDays } from './journeyCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
import type { JourneyTrack } from '@trek/shared'
interface MapEntry {
@@ -29,6 +30,11 @@ interface Props {
carouselBottom?: string
/** CARTO key from the share payload, forwarded to the map (#2054). */
cartoApiKey?: string
/** Off when the journey has put that field away (journey settings). */
showMood?: boolean
showWeather?: boolean
/** Which entry to open on. Today's, when today is part of the journey (#2299). */
initialEntryId?: string | null
}
export default function MobileMapTimeline({
@@ -43,21 +49,15 @@ export default function MobileMapTimeline({
publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
cartoApiKey,
showMood = true,
showWeather = true,
initialEntryId,
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)))]
const counters = new Map<string, number>()
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
})
}, [entries])
const scrubberDays = useMemo(() => journeyDays(entries), [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => {
@@ -141,15 +141,37 @@ export default function MobileMapTimeline({
}
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
// The day bar lands on the first entry of that day, which is where a reader
// who asked for "day nine" means.
const jumpToDay = useCallback((date: string) => {
const idx = entries.findIndex((e: any) => e.entry_date === date)
if (idx === -1) return
setActiveIndex(idx)
syncMapToCarousel(idx)
scrollCardIntoCenter(idx)
}, [entries, scrollCardIntoCenter, syncMapToCarousel])
// Initial map focus — delay to let Leaflet initialize and fitBounds. Also
// re-runs when the markers arrive later than the entries, otherwise the
// focus would fire against an empty map and never be retried.
const openedRef = useRef(false)
useEffect(() => {
if (entries.length > 0) {
const timer = setTimeout(() => syncMapToCarouselRef.current(activeIndexRef.current), 500)
return () => clearTimeout(timer)
}
}, [entries.length, mapEntries.length])
if (entries.length === 0) return
const timer = setTimeout(() => {
if (!openedRef.current && initialEntryId) {
openedRef.current = true
const idx = entries.findIndex((e: any) => String(e.id) === initialEntryId)
if (idx > 0) {
setActiveIndex(idx)
cardRefs.current.get(idx)?.scrollIntoView({ inline: 'center', block: 'nearest' })
syncMapToCarouselRef.current(idx)
return
}
}
syncMapToCarouselRef.current(activeIndexRef.current)
}, 500)
return () => clearTimeout(timer)
}, [entries.length, mapEntries.length, initialEntryId])
const activeEntryId = entries[activeIndex]
? String(entries[activeIndex].id)
@@ -204,18 +226,23 @@ export default function MobileMapTimeline({
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={200}
paddingBottom={250}
cartoApiKey={cartoApiKey}
/>
{/* Bottom carousel */}
{/* Day bar + card carousel, as one block at the bottom of the map */}
<div
className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
>
<JourneyDayScrubber
days={scrubberDays}
activeDate={entries[activeIndex]?.entry_date ?? null}
onPick={jumpToDay}
/>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-3 pt-1"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
@@ -230,13 +257,14 @@ export default function MobileMapTimeline({
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
style={{ scrollSnapAlign: 'center' }}
>
<MobileEntryCard
<JourneyEntryCover
entry={entry}
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl}
showMood={showMood}
showWeather={showWeather}
photoUrlFor={publicPhotoUrl}
/>
</div>
))}
@@ -247,7 +275,7 @@ export default function MobileMapTimeline({
{!readOnly && onAddEntry && (
<div
className="fixed right-4 z-30"
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 226px)' }}
>
<button type="button"
onClick={onAddEntry}
@@ -0,0 +1,86 @@
// FE-JRN-CARDMODEL-001 to FE-JRN-CARDMODEL-010
import { describe, it, expect } from 'vitest'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, countryBadge, dayColorOf, journeyDays } from './journeyCard'
import { DAY_COLORS } from './dayColors'
const t = (key: string) => key
describe('countryBadge', () => {
it('FE-JRN-CARDMODEL-001: stamps the ISO code, upper case', () => {
// Letters, not the flag emoji: Windows has no flag glyphs and drew the bare
// code anyway, which read as something broken.
expect(countryBadge('DE')).toBe('DE')
expect(countryBadge('jp')).toBe('JP')
})
it('FE-JRN-CARDMODEL-002: stamps nothing when there is no country to stamp', () => {
expect(countryBadge(null)).toBe('')
expect(countryBadge(undefined)).toBe('')
expect(countryBadge('')).toBe('')
expect(countryBadge('DEU')).toBe('')
expect(countryBadge('D1')).toBe('')
})
})
describe('cardTitle', () => {
it('FE-JRN-CARDMODEL-003: uses the title when there is one', () => {
expect(cardTitle({ title: 'Mercado', type: 'entry', location_name: 'Lisbon' }, t)).toBe('Mercado')
})
it('FE-JRN-CARDMODEL-004: says what a suggestion is rather than calling it untitled', () => {
expect(cardTitle({ title: null, type: 'skeleton', location_name: 'Museum' }, t)).toBe('journey.entry.suggestion')
})
it('FE-JRN-CARDMODEL-005: falls back to the place before it falls back to a placeholder', () => {
// Where you were is at least true; "Untitled" says nothing at all.
expect(cardTitle({ title: ' ', type: 'entry', location_name: 'Porto, Portugal' }, t)).toBe('Porto, Portugal')
expect(cardTitle({ title: null, type: 'entry', location_name: null }, t)).toBe('journey.editor.titlePlaceholder')
expect(cardTitle({ title: null, type: 'checkin', location_name: null }, t)).toBe('journey.detail.journeyTab')
})
})
describe('cardPlace', () => {
it('FE-JRN-CARDMODEL-006: is left out when the title is already the place', () => {
// cardTitle promoted the place to the title, so repeating it below would be
// the same words twice on a card that has room for two lines.
expect(cardPlace({ title: null, location_name: 'Porto, Portugal' })).toBe('')
expect(cardPlace({ title: 'Mercado', location_name: 'Porto, Portugal' })).toBe('Porto, Portugal')
expect(cardPlace({ title: 'Mercado', location_name: null })).toBe('')
})
})
describe('cardPhotoId', () => {
it('FE-JRN-CARDMODEL-007: reads either shape a shell hands over', () => {
expect(cardPhotoId({ photo_id: 4, id: 9 })).toBe(4)
expect(cardPhotoId({ id: 9 })).toBe(9)
expect(cardPhotoId(undefined)).toBeUndefined()
})
})
describe('cardDateLabel', () => {
it('FE-JRN-CARDMODEL-008: is short, and in the reader locale', () => {
expect(cardDateLabel('2026-03-15', 'en-GB')).toBe('15 Mar')
})
})
describe('journeyDays', () => {
it('FE-JRN-CARDMODEL-009: one entry per day, in order, coloured by position', () => {
const days = journeyDays([
{ entry_date: '2026-03-16' },
{ entry_date: '2026-03-15' },
{ entry_date: '2026-03-16' },
])
expect(days).toEqual([
{ date: '2026-03-15', color: DAY_COLORS[0] },
{ date: '2026-03-16', color: DAY_COLORS[1] },
])
})
it('FE-JRN-CARDMODEL-010: a date nobody knows still gets a colour', () => {
const days = journeyDays([{ entry_date: '2026-03-15' }])
expect(dayColorOf(days, '2026-03-15')).toBe(DAY_COLORS[0])
expect(dayColorOf(days, '2099-01-01')).toBe(DAY_COLORS[0])
})
})
@@ -0,0 +1,94 @@
import { formatLocationName } from '../../utils/formatters'
import { DAY_COLORS } from './dayColors'
import type { JourneyEntry } from '../../store/journeyStore'
/**
* What a timeline card needs to know about an entry, worked out once.
*
* The phone shell and the tablet/public timeline draw the same card in two
* different token sets, so the markup lives in `JourneyEntryCover` and the
* questions it asks live here. Anything a card has to decide — which photo,
* what to call an entry with no title, which flag — is decided in this file so
* the two surfaces cannot answer it differently.
*/
/** A photo as either shell hands it over: the journey's own carry `photo_id`, shared ones only `id`. */
export interface CardPhoto {
photo_id?: number
id?: number
}
export function cardPhotoId(photo: CardPhoto | undefined): number | undefined {
return photo?.photo_id ?? photo?.id
}
/**
* The country an entry happened in, as the two letters to stamp on its card.
*
* Letters rather than the flag emoji: the two regional-indicator code points
* that make a flag are only drawn as one on Apple platforms and Android. Windows
* has never shipped the glyphs, so a card there showed a bare "DE" where a flag
* was clearly meant — which reads as something broken rather than as a country.
* The code reads the same everywhere, and in a chip it looks deliberate.
*/
export function countryBadge(code: string | null | undefined): string {
if (!code || !/^[A-Za-z]{2}$/.test(code)) return ''
return code.toUpperCase()
}
/** Short date for the card corner: "12 Sep", in the reader's locale and order. */
export function cardDateLabel(entryDate: string, locale: string): string {
return new Date(entryDate + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
}
/**
* The name to put on a card.
*
* An untitled entry is common — quick capture used to have no title field at all
* — so the fallback matters as much as the title. A suggestion says what it is,
* because a card that says "Untitled" over a photo of somewhere you have not been
* yet is a lie; anything else falls back to its place before it falls back to a
* placeholder, since where you were is at least true.
*/
export function cardTitle(
entry: Pick<JourneyEntry, 'title' | 'type' | 'location_name'>,
t: (key: string) => string,
): string {
if (entry.title?.trim()) return entry.title
if (entry.type === 'skeleton') return t('journey.entry.suggestion')
const place = formatLocationName(entry.location_name)
if (place) return place
return entry.type === 'checkin' ? t('journey.detail.journeyTab') : t('journey.editor.titlePlaceholder')
}
/** The place line under the title, left out when the title is already the place. */
export function cardPlace(
entry: Pick<JourneyEntry, 'title' | 'location_name'>,
): string {
const place = formatLocationName(entry.location_name)
if (!place) return ''
return entry.title?.trim() ? place : ''
}
/** One day of a journey, in the order it happened, with the colour everything else uses for it. */
export interface JourneyDay {
date: string
color: string
}
/**
* The days a set of entries spans, and which colour each of them wears.
*
* The map markers, the card edges and the day bar all have to agree on "day
* three is teal", and they used to work it out separately from the same list —
* which held only as long as all three filtered that list identically.
*/
export function journeyDays(entries: { entry_date: string }[]): JourneyDay[] {
const dates = [...new Set(entries.map(e => e.entry_date))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
return dates.map((date, i) => ({ date, color: DAY_COLORS[i % DAY_COLORS.length] }))
}
/** Look a date's colour up out of `journeyDays`, falling back to the first day's. */
export function dayColorOf(days: JourneyDay[], date: string): string {
return days.find(d => d.date === date)?.color ?? DAY_COLORS[0]
}
@@ -0,0 +1,217 @@
import { escapeHtml } from '@trek/shared'
/**
* The card a journey marker shows: what the entry is called, where and when, and
* a glimpse of its photos.
*
* One module for both renderers. Leaflet had a plain one-line label and the GL
* map a two-line card, so the same marker said different things depending on a
* setting nobody links to the journey — and adding the photo strip to only one
* of them would have widened that split (discussion #2299).
*
* It is an HTML string rather than a component because both map libraries take
* markup, not React: MapLibre through `Popup.setHTML`, Leaflet through
* `bindTooltip`. Everything that comes from a person — a title, a place — is
* escaped here, once, because this markup is also what the public journey page
* hands to strangers.
*/
export interface JourneyPopupContent {
title: string
place?: string
date?: string
/** Already-resolved thumbnail URLs. The share view signs its own, so the caller builds them. */
photoUrls?: string[]
}
/**
* The attribute a clickable thumbnail carries, holding its index in the entry.
*
* A delegated listener on the popup reads it: the card is an HTML string handed
* to a map library, so there is no React tree to hang an onClick on.
*/
export const SHOT_INDEX_ATTR = 'data-shot'
/** At most this many thumbnails; beyond three the strip stops being a glimpse. */
const MAX_PHOTOS = 3
/**
* The short date for a marker card: "12 Sep", in the reader's own order.
*
* Takes the entry's stored date, which is a plain day with no clock, and pins it
* to local midnight — parsed bare, a `YYYY-MM-DD` is UTC and slides a day back
* for every reader west of Greenwich.
*/
export function formatMarkerDate(iso: string): string {
if (!iso) return ''
try {
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
if (Number.isNaN(d.getTime())) return iso
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
} catch {
return iso
}
}
export function journeyPopupHtml({ title, place, date, photoUrls = [] }: JourneyPopupContent): string {
const shots = photoUrls.slice(0, MAX_PHOTOS)
// Columns from the count, not a fixed three: two pictures should fill the card,
// not sit next to a gap where a third would have gone.
const photos = shots.length
? `<div class="trek-journey-popup-shots${shots.length === 1 ? ' is-single' : ''}" style="grid-template-columns:repeat(${shots.length},1fr)">${shots
.map((url, i) => `<span class="trek-journey-popup-shot" ${SHOT_INDEX_ATTR}="${i}" style="background-image:url('${escapeHtml(url)}')"></span>`)
.join('')}</div>`
: ''
const chips: string[] = []
if (place) chips.push(`<span class="trek-journey-popup-chip trek-journey-popup-place">${PIN_ICON}<span>${escapeHtml(place)}</span></span>`)
if (date) chips.push(`<span class="trek-journey-popup-chip trek-journey-popup-date">${CAL_ICON}<span>${escapeHtml(date)}</span></span>`)
return `${photos}
<div class="trek-journey-popup-title">${escapeHtml(title)}</div>
${chips.length ? `<div class="trek-journey-popup-sub">${chips.join('')}</div>` : ''}`
}
// Inline rather than the lucide components: this markup lives outside React's tree.
const PIN_ICON =
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>'
const CAL_ICON =
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'
/**
* Inject the card's styles once per document.
*
* A stylesheet rather than inline styles because the card has states (dark, the
* single-photo strip) and two hosts. `.trek-journey-popup` is MapLibre's popup
* shell; `.trek-journey-tooltip` is Leaflet's, whose own chrome is stripped back
* so the card inside provides all of it.
*/
export function ensureJourneyPopupStyle(): void {
if (document.getElementById('trek-journey-popup-style')) return
const s = document.createElement('style')
s.id = 'trek-journey-popup-style'
s.textContent = `
.mapboxgl-popup.trek-journey-popup,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
/* The card as a whole never takes the pointer — it sits over the map and would
swallow drags. The photo strip is the one exception, and only where the host
wired a handler for it. */
.trek-journey-popup.is-interactive .trek-journey-popup-shots { pointer-events: auto; cursor: zoom-in; }
.trek-journey-popup.is-interactive .trek-journey-popup-shot:hover { filter: brightness(1.06); }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content,
.leaflet-tooltip.trek-journey-tooltip {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
font-family: var(--font-system);
min-width: 160px;
max-width: 280px;
white-space: normal;
}
/* Leaflet paints its own tip and font on the tooltip; the card supplies both. */
.leaflet-tooltip.trek-journey-tooltip { color: #18181B; font-weight: 400; }
.leaflet-tooltip.trek-journey-tooltip::before { display: none; }
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content,
.dark .leaflet-tooltip.trek-journey-tooltip {
background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA;
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94);
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
/* A glimpse of the entry's own pictures, above its name. Backgrounds rather
than <img>: no layout shift while they load, and no broken-image glyph if
one 404s — the strip just stays the placeholder grey.
Out to the card's edges via negative margins, and the rounding lives on the
strip rather than on each tile: the two corners that meet the card's top are
round, the edges where two pictures touch stay square. */
.trek-journey-popup-shots {
display: grid;
gap: 2px;
margin: -9px -14px 9px;
border-radius: 13px 13px 0 0;
overflow: hidden;
}
.trek-journey-popup-shot {
display: block;
height: 62px;
background-color: rgba(0, 0, 0, 0.06);
background-size: cover;
background-position: center;
}
.trek-journey-popup-shots.is-single .trek-journey-popup-shot { height: 96px; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-shot,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-shot,
.dark .leaflet-tooltip.trek-journey-tooltip .trek-journey-popup-shot {
background-color: rgba(255, 255, 255, 0.08);
}
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: #18181B;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.dark .leaflet-tooltip.trek-journey-tooltip .trek-journey-popup-title { color: #FAFAFA; }
/* Place and date as their own chips rather than one grey line divided by a
middle dot: two different kinds of fact should not read as one sentence. */
.trek-journey-popup-sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
line-height: 1;
}
.trek-journey-popup-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.05);
color: #52525B;
font-size: 10.5px;
font-weight: 600;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-chip,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-chip,
.dark .leaflet-tooltip.trek-journey-tooltip .trek-journey-popup-chip {
background: rgba(255, 255, 255, 0.1);
color: #D4D4D8;
}
.trek-journey-popup-chip svg { flex: 0 0 auto; opacity: 0.7; }
.trek-journey-popup-chip span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.trek-journey-popup-place { min-width: 0; }
.trek-journey-popup-date { flex: 0 0 auto; }
@keyframes trek-journey-popup-in {
from { opacity: 0; }
to { opacity: 1; }
}
`
document.head.appendChild(s)
}
@@ -0,0 +1,37 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Marker, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
useEffect(() => () => { popup.current?.remove() }, [pois, enabled, zoom])
useEffect(() => {
const update = () => setZoom(map.getZoom())
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
@@ -0,0 +1,169 @@
/**
* FE-DAWARICH-TRAILUI-010 to FE-DAWARICH-TRAILUI-015: the recorded-route
* overlay on the Leaflet renderer.
*
* Everything this component decides is a decision about legibility on top of
* somebody else's map, and every one of them is the kind that quietly rots:
*
* - **two lines per segment, casing first.** The white casing is what keeps
* the route visible over a satellite tile. It only works while it is drawn
* *under* the coloured line, so the order of the two `map()` blocks is
* load-bearing and not cosmetic.
* - **`interactive: false` on both.** A Leaflet path without it grows an
* invisible hit area that lies over the places and the planned route and
* eats the clicks meant for them. Losing this flag breaks selection on a
* surface that has nothing to do with Dawarich, which is why it is pinned
* here rather than left to a reviewer's eye.
* - **colour through `pathOptions`**, because react-leaflet only calls
* `setStyle` when that object's identity changes; a bare `color` prop sticks
* at its mount-time value and a day recolour never arrives.
* - **the day filter keeps the day's colour.** The colour index comes from the
* day's place in the whole track, so scrubbing to a single day dims the rest
* of the trip instead of repainting the day the user is looking at.
*
* The empty case matters too: returning `null` rather than an empty fragment is
* what stops Leaflet from being handed zero-length polylines on every trip that
* has no recording.
*/
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import type { DawarichTrack, DawarichTrackDay } from '@trek/shared'
import { DAWARICH_DAY_COLORS, DAWARICH_TRAIL_CASING } from './dawarichTrail'
// Leaflet itself is irrelevant here. What is under test is which polylines are
// asked for and with what options, so the mock reflects the props back as data
// attributes and the assertions read them in render order.
vi.mock('react-leaflet', () => ({
Polyline: ({
positions,
pathOptions,
pane,
interactive,
}: {
positions: Array<[number, number]>
pathOptions?: Record<string, unknown>
pane?: string
interactive?: boolean
}) => (
<div
data-testid="polyline"
data-points={JSON.stringify(positions)}
data-color={String(pathOptions?.color ?? '')}
data-weight={String(pathOptions?.weight ?? '')}
data-opacity={String(pathOptions?.opacity ?? '')}
data-dash={String(pathOptions?.dashArray ?? '')}
data-pane={pane ?? ''}
data-interactive={String(interactive)}
/>
),
}))
import DawarichTrailLayer from './DawarichTrailLayer'
function day(date: string, ...segments: Array<Array<[number, number]>>): DawarichTrackDay {
return {
date,
segments: segments.map((points, i) => ({
points,
mode: 'walking',
startedAt: date + 'T08:0' + i + ':00.000Z',
endedAt: date + 'T09:0' + i + ':00.000Z',
distanceMeters: 1200,
})),
}
}
function track(days: DawarichTrackDay[]): DawarichTrack {
return {
days,
source: 'tracks',
fetchedAt: '2026-05-03T10:00:00.000Z',
pointCount: days.reduce((sum, d) => sum + d.segments.reduce((n, s) => n + s.points.length, 0), 0),
truncated: false,
}
}
const DAY_ONE = day('2026-05-01', [[48.2, 16.37], [48.21, 16.38]])
const DAY_TWO = day('2026-05-02', [[45.46, 9.18], [45.47, 9.19]], [[45.5, 9.2], [45.51, 9.21]])
const lines = () => screen.queryAllByTestId('polyline')
describe('DawarichTrailLayer', () => {
it('FE-DAWARICH-TRAILUI-010: draws nothing at all when there is no recording', () => {
const { container } = render(<DawarichTrailLayer track={null} />)
expect(container.firstChild).toBeNull()
})
it('FE-DAWARICH-TRAILUI-011: ignores a segment that is a single fix rather than a line', () => {
// Dawarich reports a lone point for a day someone only opened the app on.
// Leaflet would accept the one-point polyline and draw nothing visible, but
// it would still be a layer, and every such day would add two of them.
const { container } = render(<DawarichTrailLayer track={track([day('2026-05-01', [[48.2, 16.37]])])} />)
expect(container.firstChild).toBeNull()
})
it('FE-DAWARICH-TRAILUI-012: draws every casing first, then every coloured line on top', () => {
render(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} casingPane="trek-track-casing" />)
const drawn = lines()
expect(drawn).toHaveLength(6) // three segments, casing + line each
// The first half is the casing pass. Reversing these two blocks would put
// the white line over the coloured one and the overlay would go white.
const casings = drawn.slice(0, 3)
const coloured = drawn.slice(3)
expect(casings.every(el => el.getAttribute('data-color') === DAWARICH_TRAIL_CASING)).toBe(true)
expect(coloured.some(el => el.getAttribute('data-color') === DAWARICH_TRAIL_CASING)).toBe(false)
})
it('FE-DAWARICH-TRAILUI-013: gives the casing the pane and the weight that make it a casing, and nothing catches clicks', () => {
render(<DawarichTrailLayer track={track([DAY_ONE])} casingPane="trek-track-casing" />)
const [casing, line] = lines()
expect(casing.getAttribute('data-pane')).toBe('trek-track-casing')
expect(casing.getAttribute('data-weight')).toBe('6')
expect(casing.getAttribute('data-opacity')).toBe('0.55')
// Only the casing takes the pane; the coloured line is left in Leaflet's
// default overlay pane. Pinned as the fact it is rather than as a rule:
// that pane also holds the planned route, and the trail is mounted after
// it, so the recorded line currently draws over the plan while the GL twin
// puts both of its layers under it.
expect(line.getAttribute('data-pane')).toBe('')
expect(line.getAttribute('data-weight')).toBe('3')
expect(line.getAttribute('data-dash')).toBe('6 5')
expect(line.getAttribute('data-points')).toBe(JSON.stringify([[48.2, 16.37], [48.21, 16.38]]))
expect(lines().every(el => el.getAttribute('data-interactive') === 'false')).toBe(true)
})
it('FE-DAWARICH-TRAILUI-014: works without a casing pane on a renderer that has none', () => {
render(<DawarichTrailLayer track={track([DAY_ONE])} />)
// An older Leaflet build, or the GL renderer's Leaflet fallback, has no
// pane API and MapView passes undefined. Everything then lands in the
// default overlay pane in insertion order, which still keeps the casing
// under its own line, the one piece of the stacking that has to hold.
expect(lines().every(el => el.getAttribute('data-pane') === '')).toBe(true)
expect(lines()).toHaveLength(2)
})
it('FE-DAWARICH-TRAILUI-015: scrubbing to one day keeps that day its own colour', () => {
const { rerender } = render(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-02" />)
// Two segments on the second day, so two casings and two lines, and the hue
// is the SECOND of the palette even though it is now the only day drawn.
expect(lines()).toHaveLength(4)
expect(lines().slice(2).map(el => el.getAttribute('data-color')))
.toEqual([DAWARICH_DAY_COLORS[1], DAWARICH_DAY_COLORS[1]])
rerender(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-01" />)
expect(lines().slice(1).map(el => el.getAttribute('data-color'))).toEqual([DAWARICH_DAY_COLORS[0]])
// A day nobody recorded on falls back to the empty case rather than to the
// whole trip, which would be the worst possible answer to "show me Sunday".
rerender(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-09" />)
expect(lines()).toHaveLength(0)
})
})
@@ -0,0 +1,81 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
trailSegments,
type DawarichTrailSegment,
} from './dawarichTrail'
/**
* The recorded route on the Leaflet renderer.
*
* Three lines per segment, the same construction the GPX tracks next door use:
* a white casing so the line survives a satellite tile, the coloured line
* itself, and nothing on top — this overlay is not clickable. That last part is
* deliberate: a fat invisible hit-line would sit over the places and the
* planned route, and swallow the clicks that select them, in exchange for a
* popup nobody asked for.
*
* Dashed rather than solid. The planned route is already a solid line in the
* user's accent, and the whole point of this layer is to be visibly the other
* thing — what actually happened, next to what was planned. Telling them apart
* has to survive being printed, screenshotted and looked at by someone who
* cannot distinguish the two hues.
*/
export default function DawarichTrailLayer({
track,
selectedDate,
hiddenDates,
casingPane,
}: {
track: DawarichTrack | null
/** When set, only that local day is drawn. */
selectedDate?: string | null
/** Local dates whose day is folded away in the day plan. */
hiddenDates?: ReadonlySet<string> | null
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
if (segments.length === 0) return null
return (
<>
{segments.map((segment: DawarichTrailSegment) => (
<Polyline
key={`dawarich-casing-${segment.id}`}
positions={segment.points}
pane={casingPane}
// theme-lint-disable — map paint
pathOptions={{
color: DAWARICH_TRAIL_CASING,
weight: 6,
opacity: 0.55,
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
{/* pathOptions rather than bare props: react-leaflet only calls setStyle
when the pathOptions reference changes, so a bare `color` would stick
at its mount-time value and a day recolour would never arrive. */}
{segments.map((segment: DawarichTrailSegment) => (
<Polyline
key={`dawarich-line-${segment.id}`}
positions={segment.points}
pathOptions={{
color: segment.color,
weight: 3,
opacity: 0.85,
dashArray: '6 5',
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
</>
)
}
@@ -0,0 +1,149 @@
/**
* FE-DAWARICH-TRAILUI-001 to FE-DAWARICH-TRAILUI-009: the recorded-route
* toggle that sits with the compass and the layer switcher on the trip map.
*
* The pill is small but it carries the only explanation the user ever gets for
* an empty overlay. Turning the layer on and seeing no line has four different
* causes (it is still loading, nothing was recorded on these dates, the
* Dawarich instance did not answer, the device is offline), and all four are
* answered by the label on this one control. If the label chain collapses to
* "hide recorded route" the map silently lies: the user sees a pressed button,
* no line, and no reason.
*
* So what is pinned here is the mapping from `status` to the label, and the
* second, wordless channel next to it: a problem state drains the mark to 0.35
* so it is visible without hovering, while loading (which is not a problem)
* stays at full strength. The off state has to win over all of it, because a
* dimmed logo on a switched-off control would read as broken rather than as
* off.
*/
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
import { DawarichTrailPill } from './DawarichTrailPill'
import type { DawarichTrailStatus } from './useDawarichTrail'
function pill(props: Partial<React.ComponentProps<typeof DawarichTrailPill>> = {}) {
return render(
<DawarichTrailPill active={false} status="idle" onToggle={vi.fn()} {...props} />,
)
}
const button = () => screen.getByTestId('dawarich-trail-pill')
const label = () => button().getAttribute('aria-label')
afterEach(() => {
vi.useRealTimers()
})
describe('DawarichTrailPill', () => {
it('FE-DAWARICH-TRAILUI-001: offers to show the route while the layer is off, whatever the last status was', () => {
const { rerender } = pill()
expect(label()).toBe('Show recorded route')
expect(button()).toHaveAttribute('aria-pressed', 'false')
expect(button().style.opacity).toBe('0.6')
// A stale failure from the last time the layer was on must not dim a
// switched-off control: off already has its own resting opacity, and a
// second, dimmer step would read as "this button is broken".
rerender(<DawarichTrailPill active={false} status="unavailable" onToggle={vi.fn()} />)
expect(label()).toBe('Show recorded route')
expect(button().style.opacity).toBe('0.6')
})
it('FE-DAWARICH-TRAILUI-002: offers to hide the route once a line is on the map', () => {
const { rerender } = pill({ active: true, status: 'ready' })
expect(label()).toBe('Hide recorded route')
expect(button()).toHaveAttribute('aria-pressed', 'true')
expect(button().style.opacity).toBe('1')
// `idle` is the state between the toggle flipping and the first fetch
// starting. It falls through the same arm as `ready` on purpose: the button
// has just been pressed and "hide" is the only honest thing it can offer.
rerender(<DawarichTrailPill active status="idle" onToggle={vi.fn()} />)
expect(label()).toBe('Hide recorded route')
expect(button().style.opacity).toBe('1')
})
it('FE-DAWARICH-TRAILUI-003: says it is loading without dimming the mark', () => {
pill({ active: true, status: 'loading' })
expect(label()).toMatch(/^Loading the recorded route/)
// Loading is not a problem, so the wordless "something is wrong" channel
// stays off. Otherwise every refresh would blink the control.
expect(button().style.opacity).toBe('1')
})
it('FE-DAWARICH-TRAILUI-004: names each reason there is no line and dims the mark for it', () => {
const reasons: Array<[DawarichTrailStatus, string]> = [
['empty', 'Nothing was recorded on these dates'],
['offline', 'The recorded route needs a connection'],
['unavailable', 'The recorded route could not be loaded'],
]
for (const [status, expected] of reasons) {
const { unmount } = pill({ active: true, status })
expect(label()).toBe(expected)
expect(button().style.opacity).toBe('0.35')
// The pressed state survives the problem: the layer really is on, and
// un-pressing it here would make the toggle disagree with the map.
expect(button()).toHaveAttribute('aria-pressed', 'true')
unmount()
}
})
it('FE-DAWARICH-TRAILUI-005: reports a press once', () => {
const onToggle = vi.fn()
pill({ onToggle })
fireEvent.click(button())
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-TRAILUI-006: lifts the mark under the pointer and puts it back', () => {
pill({ active: true, status: 'ready' })
fireEvent.mouseEnter(button())
expect(button().style.transform).toBe('scale(1.06)')
fireEvent.mouseLeave(button())
expect(button().style.transform).toBe('none')
})
it('FE-DAWARICH-TRAILUI-007: repeats the status in a readable tooltip rather than the native one', () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
pill({ active: true, status: 'empty' })
fireEvent.mouseEnter(button())
act(() => { vi.advanceTimersByTime(300) })
// The whole reason this control carries TREK's own tooltip: the native one
// never appears on a touch device, which is exactly where someone taps the
// button to find out why the map is empty.
expect(screen.getByRole('tooltip')).toHaveTextContent('Nothing was recorded on these dates')
})
it('FE-DAWARICH-TRAILUI-008: shows a spinning ring and a busy state while the route loads', () => {
pill({ active: true, status: 'loading' })
// A first fetch from somebody's own server can take a while. With nothing on
// the button it read as "done, no line", and the tester reloaded the page.
expect(screen.getByTestId('dawarich-trail-loading')).toBeInTheDocument()
expect(button()).toHaveAttribute('aria-busy', 'true')
})
it('FE-DAWARICH-TRAILUI-009: drops the ring once there is an answer, and never shows it while the layer is off', () => {
const view = pill({ active: true, status: 'loading' })
view.rerender(<DawarichTrailPill active status="ready" onToggle={vi.fn()} />)
expect(screen.queryByTestId('dawarich-trail-loading')).not.toBeInTheDocument()
expect(button()).toHaveAttribute('aria-busy', 'false')
// Switched off mid-load: the hook still reports its last status, the button
// must not keep spinning for a layer nobody asked for any more.
view.rerender(<DawarichTrailPill active={false} status="loading" onToggle={vi.fn()} />)
expect(screen.queryByTestId('dawarich-trail-loading')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,115 @@
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import DawarichIcon from '../shared/DawarichIcon'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { DawarichTrailStatus } from './useDawarichTrail'
/**
* Toggle for the recorded-route overlay (#2279), in the same frosted shell as
* the compass, the layer switcher and the trip-route overview so the map's
* controls stay one family.
*
* The status rides on the button rather than in a banner: the states that
* matter — loading, nothing recorded for these dates, instance unreachable,
* device offline — are all answers to "why is there no line", and the place
* someone looks for that answer is the control they just pressed.
*/
export function DawarichTrailPill({
active,
status,
onToggle,
}: {
active: boolean
status: DawarichTrailStatus
onToggle: () => void
}) {
const { t } = useTranslation()
const label = !active
? t('dawarich.trail.show')
: status === 'loading'
? t('dawarich.trail.loading')
: status === 'empty'
? t('dawarich.trail.empty')
: status === 'offline'
? t('dawarich.trail.offline')
: status === 'unavailable'
? t('dawarich.trail.unavailable')
: t('dawarich.trail.hide')
// A problem worth seeing without reading the tooltip: the layer is on and
// there is nothing to show.
const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
// Loading has to be visible too. A first fetch from somebody's own server can
// take a while, and with nothing on the button it looked exactly like "done,
// but no line", so testers reloaded the page to find out.
const loading = active && status === 'loading'
return (
<div
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
padding: 4,
borderRadius: 999,
pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}
>
{/* TREK's own tooltip: the native one is unreadable in dark mode and never
appears on a touch device, and this control's whole job is to explain
why there is no line. */}
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
aria-busy={loading}
data-testid="dawarich-trail-pill"
style={{
display: 'block',
width: 34,
height: 34,
borderRadius: 999,
border: 'none',
cursor: 'pointer',
background: 'transparent',
padding: 0,
// The mark fills the circle and is clipped by it, so the control reads
// as the Dawarich button rather than as a frame with a logo in it.
overflow: 'hidden',
// Its own colours stay; on/off is carried by opacity, because a
// recoloured logo is a different logo.
opacity: muted ? 0.35 : active ? 1 : 0.6,
transition: 'opacity 0.14s, transform 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'none' }}
>
<DawarichIcon fill />
</button>
</Tooltip>
{loading && (
<span
aria-hidden="true"
data-testid="dawarich-trail-loading"
className="motion-safe:animate-spin"
style={{
position: 'absolute',
inset: 2,
borderRadius: 999,
border: '2px solid transparent',
borderTopColor: 'var(--accent)',
borderRightColor: 'var(--accent)',
pointerEvents: 'none',
}}
/>
)}
</div>
)
}
@@ -0,0 +1,15 @@
import { GeoJSON } from 'react-leaflet'
import type { RoadtripHazard } from '@trek/shared'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
/>)}</>
}
+24 -18
View File
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -21,24 +22,29 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={isSatellite}
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Icon size={17} strokeWidth={2} />
</button>
{/* Same tooltip as its neighbours on the map, for the same reasons — see
TripRouteOverviewPill. To the right, though: this one sits at the
bottom left of the map, where a tooltip to the left would land on the
day sidebar and one below would land off the screen. */}
<Tooltip label={label} placement="right">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={isSatellite}
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Icon size={17} strokeWidth={2} />
</button>
</Tooltip>
</div>
)
}
+42 -2
View File
@@ -11,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({
getContainer: vi.fn(() => document.createElement('div')),
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
@@ -64,6 +65,7 @@ vi.mock('react-leaflet', () => ({
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
<div
ref={node => { if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } }) }}
data-testid="marker"
data-lat={position[0]}
data-lng={position[1]}
@@ -266,9 +268,9 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBe(3)
})
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
it.each([false, true])('FE-COMP-MAPVIEW-010: place clustering stays enabled with roadtrip=%s', (roadtrip) => {
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
render(<MapView places={places} clusterLoosely={roadtrip} />)
expect(screen.getByTestId('cluster-group')).toBeTruthy()
})
@@ -650,6 +652,23 @@ describe('MapView explore POIs', () => {
expect(onPoiClick).toHaveBeenCalledWith(poi)
})
it('keeps native POI clicks working after a pan and uses the latest callback', () => {
const first = vi.fn()
const latest = vi.fn()
const poi = buildPoi({ osm_id: 'node/7' })
const { rerender } = render(<MapView pois={[poi]} onPoiClick={first} onPoiDropOnRoute={() => {}} />)
const marker = markersWithZ('500')[0]
expect(marker).toHaveAttribute('draggable', 'true')
fireEvent.mouseDown(marker)
fireEvent.mouseUp(marker)
fireEvent.click(marker)
expect(first).toHaveBeenCalledTimes(1)
rerender(<MapView pois={[poi]} onPoiClick={latest} onPoiDropOnRoute={() => {}} />)
fireEvent.click(markersWithZ('500')[0])
expect(latest).toHaveBeenCalledExactlyOnceWith(poi)
expect(first).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEW-035: an unknown POI category falls back to grey and draws no glyph', () => {
render(<MapView pois={[buildPoi({ osm_id: 'node/8', category: 'not-a-category' })]} />)
const html = iconHtmlOf(markersWithZ('500')[0])
@@ -698,6 +717,27 @@ describe('MapView plugin route vias', () => {
expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min')
})
it('hides night badges at wide zoom and shows the full description when zoomed in', () => {
mapMock.getZoom.mockReturnValue(5)
const label = 'Tagesende von Tag 1 um 18:00 Uhr'
render(<MapView routeVias={[via({ label, hoverCard: true, nightPause: { day: 1, atPlace: true } })]} />)
expect(markersWithZ('800')).toHaveLength(0)
mapMock.getZoom.mockReturnValue(6)
act(() => { mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]() })
const badge = markersWithZ('800')[0]
expect(iconHtmlOf(badge)).toContain('data-night-pause="place"')
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByRole('tooltip')).toHaveTextContent(label)
mapMock.getZoom.mockReturnValue(5)
act(() => {
mapMock.on.mock.calls.find(([events]) => events === 'movestart zoomstart')![1]()
mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]()
})
expect(markersWithZ('800')).toHaveLength(0)
expect(screen.queryByRole('tooltip')).toBeNull()
mapMock.getZoom.mockReturnValue(10)
})
it('FE-COMP-MAPVIEW-041: a dwell under an hour is shown in minutes alone', () => {
render(<MapView routeVias={[via({ dwellSeconds: 1500 })]} />)
expect(markersWithZ('800')[0].textContent).toContain('25 min')
+142 -73
View File
@@ -5,6 +5,7 @@ import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import HazardLayers from './HazardLayers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -35,7 +36,9 @@ import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import PlaceHoverCard from './PlaceHoverCard'
import { ratingBadgeHtml } from './ratingBadge'
import ReservationOverlay from './ReservationOverlay'
import { PluginMapMarkers } from './MapPluginMarkers'
import { PluginMapLayers } from './MapPluginLayers'
@@ -44,10 +47,18 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import NightPauseTooltip from './NightPauseTooltip'
import ClusteredPois from './ClusteredPois'
import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
import { resolveBasemap } from '../../utils/tileUrl'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher } from './MapLayerSwitcher'
@@ -98,12 +109,32 @@ function formatViaDwell(seconds: number): string {
return h > 0 ? `${h} h ${m} min` : `${m} min`
}
function nightPauseIcon(via: RouteVia): L.DivIcon {
const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}`
const cached = viaIconCache.get(key)
if (cached) return cached
const icon = L.divIcon({ className: 'night-pause-marker', html: nightPauseMarker(via), iconSize: [0, 0], iconAnchor: [0, 0] })
viaIconCache.set(key, icon)
return icon
}
function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
</Marker>
}
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}:${(place as { rating_avg?: number | null }).rating_avg ?? ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
@@ -128,8 +159,10 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = safeHexColor(place.category_color, '#6b7280')
// Number badges (bottom-right)
let badgeHtml = ''
// Number badges (bottom-right), or the rating where there are none: a numbered stop
// is one already planned into a day, and the rating answers the question asked before
// that. The two never want the same corner at the same time.
let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg)
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
badgeHtml = `<span style="
@@ -226,15 +259,16 @@ function createPoiIcon(category: string, brandWikidata?: string | null) {
// away under a stationary cursor, so the browser never fires mouseout — and
// mouseover/mousemove during the pan animation would immediately re-set the
// tooltip we just cleared (#1404).
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { current: boolean }; onMoveStart: () => void; onZoom: (zoom: number) => void }) {
const map = useMap()
useEffect(() => {
const start = () => { movingRef.current = true; onMoveStart() }
const end = () => { movingRef.current = false }
const end = () => { movingRef.current = false; onZoom(map.getZoom()) }
onZoom(map.getZoom())
map.on('movestart zoomstart', start)
map.on('moveend zoomend', end)
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
}, [map, movingRef, onMoveStart])
}, [map, movingRef, onMoveStart, onZoom])
return null
}
@@ -638,6 +672,11 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
dayBoundaryControls,
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
dawarichHiddenDates = null,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -655,9 +694,19 @@ export const MapView = memo(function MapView({
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiClickRef = useRef(onPoiClick)
poiClickRef.current = onPoiClick
/**
* Amap's tiles are drawn in GCJ-02 (see gcj02Crs.ts). The projection has to be
* decided before the map is constructed, because Leaflet cannot change a map's
* CRS afterwards — hence the whole map, not just the tile layer.
*/
const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
zIndexOffset={500}
@@ -667,7 +716,11 @@ export const MapView = memo(function MapView({
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
if (!el) return
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
el.setAttribute('aria-label', poi.name)
el.onclick = event => { event.stopPropagation(); poiClickRef.current?.(poi) }
if (onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
>
@@ -712,11 +765,12 @@ export const MapView = memo(function MapView({
})
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null)
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const [mapZoom, setMapZoom] = useState(0)
const mapMovingRef = useRef(false)
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
const handleMarkerHover = useCallback((place: MapHoverInfo, x: number, y: number) => {
if (hoverDisabled || mapMovingRef.current) return
setHoveredPlace(place)
setTooltipPos({ x, y })
@@ -920,7 +974,7 @@ export const MapView = memo(function MapView({
), [gpxTracks, hasCasingPane])
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy),
@@ -953,6 +1007,15 @@ export const MapView = memo(function MapView({
<>
<div className="w-full h-full relative">
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
// late — a first GET that fails leaves the store on defaults until
// ensureSettingsLoaded heals it — and without this the raster layer would
// switch to Amap's GCJ-02 tiles under a map still projecting WGS-84, which
// puts every marker and route a few hundred metres off the street beneath
// it. Only the datum: keying on the whole tile URL would throw the view and
// the cluster group away on every template or API-key edit.
key={isGcjBasemap ? 'gcj02' : 'wgs84'}
id="trek-map"
center={initialView.center}
zoom={initialView.zoom}
@@ -963,6 +1026,10 @@ export const MapView = memo(function MapView({
// VectorBasemap had no ceiling at all. MarkerClusterGroup.onAdd throws
// outright on an infinite one, which took the whole planner down.
maxZoom={MAP_MAX_ZOOM}
// Omitted entirely for a WGS-84 basemap, which is every install that has
// not chosen Amap: passing L.CRS.EPSG3857 explicitly would be the same
// value, but leaving the prop off keeps those maps byte-identical.
{...(gcjCrs ? { crs: gcjCrs } : {})}
className="w-full h-full bg-[#e5e7eb]"
>
{/* The basemap is a vector style by default and a raster template when the
@@ -973,8 +1040,12 @@ export const MapView = memo(function MapView({
{isSatellite ? (
<TileLayer
key="satellite"
url={SATELLITE_TILE_URL}
attribution={SATELLITE_TILE_ATTRIBUTION}
// Esri's imagery is WGS-84 and Amap's is GCJ-02, and the map's CRS is
// already fixed by the basemap the user chose. Handing the other datum
// to that projection moves the whole photo a few hundred metres, so the
// satellite toggle follows the datum rather than always meaning Esri.
url={isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL}
attribution={attributionForTile(isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL)}
maxZoom={SATELLITE_TILE_MAXZOOM}
keepBuffer={8}
updateWhenZooming={false}
@@ -1001,32 +1072,25 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
<ViewportController onViewportChange={onViewportChange} />
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
zoomed right out, and a day's stops merging into one dot is exactly the shape
it is being looked at for. Rendered outside the group rather than with the
clustering disabled by option, so nothing is left to a plugin's own idea of
what "disabled" means. */}
{clusterLoosely ? markers : (
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
)}
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={20}
disableClusteringAtZoom={9}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
@@ -1130,19 +1194,36 @@ export const MapView = memo(function MapView({
roadRoutes={transportRoutes}
/>
{poiMarkers}
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
{/* The route as it was actually recorded (#2279). Drawn in the casing
pane's sibling order so it sits under the planned route rather than
over it — the plan is what the user is editing. */}
<DawarichTrailLayer
track={dawarichTrack}
selectedDate={dawarichSelectedDate}
hiddenDates={dawarichHiddenDates}
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
/>
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
{(routeVias as RouteVia[]).map((v, i) => (
<Marker key={`route-via-${i}`} position={[v.lat, v.lng]} icon={routeViaIcon(v.tone)} zIndexOffset={800}>
{(v.label || v.dwellSeconds != null) && (
<Tooltip direction="top" offset={[0, -8]}>
{(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => (
<RouteViaMarker key={v.nightPause ? `night-${v.nightPause.day}` : `route-via-${i}`} via={v} controls={dayBoundaryControls}
eventHandlers={v.hoverCard ? {
mouseover: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
mousemove: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
mouseout: handleMarkerHoverOut,
} : undefined}
>
{(!v.hoverCard || isTouchDevice) && (v.label || v.dwellSeconds != null) && (
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
{v.label}
{v.label && v.dwellSeconds != null ? ' · ' : ''}
{v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : ''}
</Tooltip>
)}
</Marker>
</RouteViaMarker>
))}
<PluginMapMarkers tripId={tripId} />
<PluginMapLayers tripId={tripId} />
@@ -1157,41 +1238,29 @@ export const MapView = memo(function MapView({
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
{/* Bottom left, whatever else is on screen. Opening a place used to send it to the
top of the map, which read as the control moving house rather than as room being
made: the inspector is a centred card at most 800 wide, so the corner it would
have been clearing is one the card never reaches. */}
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
{TooltipOverlay && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: tooltipPos.x + 14,
top: tooltipPos.y - 10,
zIndex: 9999,
pointerEvents: 'none',
background: 'white',
borderRadius: 8,
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
padding: '6px 10px',
fontFamily: "var(--font-system)",
maxWidth: 220,
whiteSpace: 'nowrap',
}}>
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoveredPlace.name}
</div>
{hoveredPlace.category_name && CatIcon && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
</div>
)}
{hoveredPlace.address && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoveredPlace.address}
</div>
)}
</div>
{TooltipOverlay && hoveredPlace.routeVia?.nightPause && (
<NightPauseTooltip label={hoveredPlace.name ?? ''} x={tooltipPos.x} y={tooltipPos.y} />
)}
{TooltipOverlay && !hoveredPlace.routeVia?.nightPause && (
<PlaceHoverCard
x={tooltipPos.x}
y={tooltipPos.y}
name={hoveredPlace.name}
categoryName={hoveredPlace.category_name}
categoryIcon={hoveredPlace.category_icon}
categoryColor={hoveredPlace.category_color}
address={hoveredPlace.address}
rating={hoveredPlace.rating_avg}
/>
)}
</>
)
+14 -4
View File
@@ -1,8 +1,12 @@
import { Suspense } from 'react'
import { AlertTriangle, Clock, Info } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
import ErrorBoundary from '../shared/ErrorBoundary'
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
@@ -14,6 +18,12 @@ import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
// them online (see the GL tile rules in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
// `dawarichTrack` arrives as a prop rather than being fetched here: the pill
// that switches it on lives at page level and needs the load status, so the
// fetch sits in useTripPlanner and both shells read the same one.
const mapProps = { ...props, hazards: hazards.feed?.hazards }
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set,
@@ -33,12 +43,12 @@ export function MapViewAuto(props: any) {
// to Leaflet keeps a usable map instead of an error card.
// resetKeys: with two engine chunks, a failure under one provider must not
// keep showing Leaflet after the user switches to the other.
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...props} />}>
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
<Suspense fallback={<MapView {...mapProps} />}>
<MapViewGL {...mapProps} glProvider={glProvider} />
</Suspense>
</ErrorBoundary>
)
}
return <MapView {...props} />
return <MapView {...mapProps} />
}
+33 -2
View File
@@ -34,6 +34,11 @@ const glMap = vi.hoisted(() => ({
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
// Every overlay that adds a layer takes it away again on cleanup; a real
// MapLibre/Mapbox map has both halves, and a double with only the adding half
// fails the teardown rather than the behaviour under test.
removeLayer: vi.fn(),
removeSource: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
@@ -408,8 +413,8 @@ describe('MapViewGL', () => {
expect(glMap.addSource).toHaveBeenCalledWith('trip-place-clusters', expect.objectContaining({
type: 'geojson',
cluster: true,
clusterRadius: 30,
clusterMaxZoom: 10,
clusterRadius: 20,
clusterMaxZoom: 8,
}))
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' }))
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' }))
@@ -1389,6 +1394,32 @@ describe('MapViewGL', () => {
expect(glMarkers.created[1].element.innerHTML).toContain('#6b7280')
})
it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => {
loadOnAttach()
vi.mocked(glMap.getZoom).mockReturnValue(8)
const poi: Poi = {
osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station',
poi_type: 'charging_station', address: null, website: null, phone: null,
opening_hours: null, cuisine: null, source: 'openstreetmap',
}
const other = { ...poi, osm_id: 'n2', name: 'Station B' }
const onPoiClick = vi.fn()
render(<MapViewGL places={[]} fitKey={1} pois={[poi, other]} clusterLoosely onPoiClick={onPoiClick} />)
await act(async () => {})
const cluster = glMarkers.created.find(pin => pin.element.querySelector('[data-poi-cluster="2"]'))!
expect(cluster).toBeDefined()
act(() => { cluster.element.click() })
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), { padding: 70, maxZoom: 18 })
expect(onPoiClick).not.toHaveBeenCalled()
vi.mocked(glMap.getZoom).mockReturnValueOnce(18)
act(() => { cluster.element.click() })
const calls = vi.mocked(glPopup.setDOMContent).mock.calls
const list = calls[calls.length - 1][0]
act(() => { list.querySelectorAll('button')[1].click() })
expect(onPoiClick).toHaveBeenCalledWith(other)
vi.mocked(glMap.getZoom).mockReturnValue(10)
})
it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => {
loadOnAttach()
const markers: PluginMapMarker[] = [
+149 -72
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import type { RoadtripVia } from '@trek/shared'
import type { DawarichTrack, RoadtripVia } from '@trek/shared'
import { useStableVias } from './viaMarkerState'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -24,6 +25,16 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
import { useDawarichTrailGL } from './useDawarichTrailGL'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip'
import PlaceHoverCard from './PlaceHoverCard'
import { ratingBadgeHtml } from './ratingBadge'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
@@ -120,6 +131,7 @@ interface Props {
tripId?: number | string
// Charging stops / rest areas a plugin route places on the drawn day route.
routeVias?: RouteVia[]
dayBoundaryControls?: DayBoundaryControls
/** The dashed last bit to a place the road network does not reach. */
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
route?: [number, number][][] | null
@@ -180,6 +192,13 @@ interface Props {
* motorway are the shape of the day, and merging them into one dot hides it.
*/
clusterLoosely?: boolean
hazards?: RoadtripHazard[]
/** The route recorded in Dawarich, already fetched by MapViewAuto (#2279). */
dawarichTrack?: DawarichTrack | null
/** Draw only this local day of the recording. */
dawarichSelectedDate?: string | null
/** Local dates whose day is collapsed in the day plan; their recording is not drawn. */
dawarichHiddenDates?: ReadonlySet<string> | null
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
@@ -197,9 +216,9 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster, outside road trip mode. */
const CLUSTER_RADIUS = 30
const CLUSTER_MAX_ZOOM = 10
/** How eagerly place markers merge into a cluster. */
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
@@ -321,7 +340,9 @@ function createMarkerElement(place: Place & { category_color?: string; category_
// bleed outside it and the route lines would appear slightly off.
const outer = size + borderWidth * 2
let badgeHtml = ''
// Same corner, same rule as the Leaflet map: numbers when the place is planned into a
// day, the rating when it is not.
let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg)
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
badgeHtml = `<span style="
@@ -609,6 +630,7 @@ export function MapViewGL({
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
dayBoundaryControls,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
@@ -623,6 +645,10 @@ export function MapViewGL({
fitKey = 0,
focusPoints,
clusterLoosely = false,
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
dawarichHiddenDates = null,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -651,6 +677,7 @@ export function MapViewGL({
gl,
onMapReady,
}: Props) {
const { t } = useTranslation()
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -675,7 +702,7 @@ export function MapViewGL({
const [mapReady, setMapReady] = useState(false)
// Hover tooltip — a cursor-following name/category/address card, matching the
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
const [hoverPlace, setHoverPlace] = useState<MapHoverInfo | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const hoverIdRef = useRef<number | null>(null)
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
@@ -697,6 +724,10 @@ export function MapViewGL({
const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
@@ -1781,18 +1812,6 @@ export function MapViewGL({
return
}
// Road trip mode does not cluster at all: the whole drive has to stay readable
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
// is being looked at for. The source is emptied rather than reconfigured — the
// cluster options can only be set when a GeoJSON source is created, and dropping and
// re-adding a source under three layers to change a radius is not worth it when the
// markers are drawn from `places` anyway.
if (clusterLoosely) {
source.setData({ type: 'FeatureCollection', features: [] })
reconcileMarkers(validPlaces)
return
}
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
@@ -1833,29 +1852,63 @@ export function MapViewGL({
}
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
// Search results stay separate from the planned itinerary.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
const redraw = () => {
popupRef.current?.remove()
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat]), map.getZoom()) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
for (const group of groups) {
if (group.pois.length > 1) {
const el = document.createElement('button')
el.type = 'button'
el.style.cssText = 'width:38px;height:38px;border:0;padding:0;background:transparent;'
el.setAttribute('aria-label', t('roadtrip.poi.found', { count: group.pois.length }))
el.innerHTML = poiClusterMarkup(group.pois.length)
el.addEventListener('click', event => {
event.stopPropagation()
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popupRef.current?.setLngLat([group.lng, group.lat]).setDOMContent(poiClusterList(group.pois, poi => {
popupRef.current?.remove(); onPoiClickRef.current?.(poi)
})).addTo(map)
} else {
const bounds = new gl.LngLatBounds()
group.pois.forEach(poi => bounds.extend([poi.lng, poi.lat]))
map.fitBounds(bounds, { padding: 70, maxZoom: POI_CLUSTER_DETAIL_ZOOM })
}
})
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, group.lng, group.lat))
continue
}
const poi = group.pois[0]
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
}
}
}, [pois, mapReady, glProvider])
redraw()
if (clusterLoosely) map.on('moveend', redraw)
return () => {
map.off('moveend', redraw)
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
poiMarkersRef.current.forEach(pin => pin.remove())
poiMarkersRef.current = []
popupRef.current?.remove()
}
}, [pois, mapReady, glProvider, clusterLoosely, t])
// Fetch plugin map contributions (markers + layers) per trip. Fail-safe: an
// error or missing tripId just means no plugin overlays, the core map is fine.
@@ -1887,11 +1940,30 @@ export function MapViewGL({
if (!map || !mapReady) return
routeViaMarkersRef.current.forEach(m => m.remove())
routeViaMarkersRef.current = []
const pauses: HTMLElement[] = []
const unbind: (() => void)[] = []
for (const v of routeVias) {
const el = document.createElement('div')
el.style.cssText = 'width:13px;height:13px;cursor:pointer;'
const color = PLUGIN_TONE_COLORS[v.tone] ?? PLUGIN_TONE_COLORS.default
el.innerHTML = `<span style="display:block;width:13px;height:13px;border-radius:50%;background:#fff;border:3.5px solid ${color};box-shadow:0 1px 4px rgba(0,0,0,0.35);box-sizing:border-box"></span>`
if (v.nightPause) {
el.style.cssText = 'width:0;height:0;overflow:visible;cursor:pointer;'
el.innerHTML = nightPauseMarker(v)
el.setAttribute('aria-label', v.label ?? '')
pauses.push(el)
}
if (v.hoverCard) {
const show = (ev: MouseEvent) => {
if (hoverDisabledRef.current || camMovingRef.current) return
hoverIdRef.current = null
setHoverPlace({ name: v.label, routeVia: v })
setHoverPos({ x: ev.clientX, y: ev.clientY })
}
el.addEventListener('mouseenter', show)
el.addEventListener('mousemove', show)
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
}
if (v.label || v.dwellSeconds != null) {
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
el.addEventListener('click', (ev) => {
@@ -1899,9 +1971,30 @@ export function MapViewGL({
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
})
}
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
const pin = attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat)
routeViaMarkersRef.current.push(pin)
if (v.nightPause && dayBoundaryControls) unbind.push(bindDayBoundaryDrag(el, v, dayBoundaryControls, {
project: (lat, lng) => {
const point = map.project([lng, lat])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { pin.setLngLat([lng, lat]) },
lock: () => {
const enabled = map.dragPan.isEnabled()
map.dragPan.disable()
return () => { if (enabled) map.dragPan.enable() }
},
}))
}
}, [routeVias, mapReady, glProvider])
const updateZoom = () => {
const visible = map.getZoom() >= NIGHT_PAUSE_MIN_ZOOM
for (const el of pauses) el.style.display = visible ? '' : 'none'
}
updateZoom()
map.on('zoomend', updateZoom)
return () => { map.off('zoomend', updateZoom); unbind.forEach(dispose => dispose()) }
}, [routeVias, mapReady, glProvider, dayBoundaryControls])
// Reconcile plugin markers (imperative, same lifecycle as the POI markers).
useEffect(() => {
@@ -2202,8 +2295,6 @@ export function MapViewGL({
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
const HoverIcon = (hoverPlace?.category_icon && CATEGORY_ICON_MAP[hoverPlace.category_icon]) || CATEGORY_ICON_MAP['MapPin']
return (
<div className="w-full h-full relative">
<div ref={containerRef} className="w-full h-full" />
@@ -2219,7 +2310,9 @@ export function MapViewGL({
{/* Same pill, same corner, same offsets as the Leaflet map: the switch should not
move when an instance changes renderer. The day panel is a centred card on the
desktop map and never reaches the pill, but on a phone it is full width and
does, which is why the lift is only there. */}
does, which is why the lift is only there. An open place inspector is a centred
card too, and used to send the pill to the top of the map for a corner it never
covers. */}
<div style={{
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
bottom: isMobile && hasDayDetail
@@ -2230,36 +2323,20 @@ export function MapViewGL({
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: hoverPos.x + 14,
top: hoverPos.y - 10,
zIndex: 9999,
pointerEvents: 'none',
background: 'white',
borderRadius: 8,
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
padding: '6px 10px',
fontFamily: 'var(--font-system)',
maxWidth: 220,
whiteSpace: 'nowrap',
}}>
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoverPlace.name}
</div>
{hoverPlace.category_name && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<HoverIcon size={10} style={{ color: hoverPlace.category_color || '#6b7280', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoverPlace.category_name}</span>
</div>
)}
{hoverPlace.address && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoverPlace.address}
</div>
)}
</div>
{!hoverDisabled && hoverPlace?.routeVia?.nightPause && hoverPos && !isMobile && routeVias.includes(hoverPlace.routeVia) && (
<NightPauseTooltip label={hoverPlace.name ?? ''} x={hoverPos.x} y={hoverPos.y} />
)}
{!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && (
<PlaceHoverCard
x={hoverPos.x}
y={hoverPos.y}
name={hoverPlace.name}
categoryName={hoverPlace.category_name}
categoryIcon={hoverPlace.category_icon}
categoryColor={hoverPlace.category_color}
address={hoverPlace.address}
rating={hoverPlace.rating_avg}
/>
)}
</div>
)
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useMap } from 'react-leaflet'
import type L from 'leaflet'
import type { RouteVia } from '../../types'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
const map = useMap()
useEffect(() => {
const el = marker?.getElement()
if (!marker || !el || !controls) return
return bindDayBoundaryDrag(el, via, controls, {
project: (lat, lng) => {
const point = map.latLngToContainerPoint([lat, lng])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
lock: () => {
const enabled = map.dragging.enabled()
map.dragging.disable()
return () => { if (enabled) map.dragging.enable() }
},
})
}, [map, marker, via, controls])
return null
}
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import NightPauseTooltip from './NightPauseTooltip'
afterEach(() => vi.restoreAllMocks())
it('keeps the full pause description inside the viewport at its lower right edge', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
const label = 'Tagesende von Tag 12 um 18:00 Uhr'
render(<NightPauseTooltip label={label} x={window.innerWidth - 5} y={window.innerHeight - 5} />)
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent(label)
expect(parseFloat(tooltip.style.left) + 240).toBeLessThanOrEqual(window.innerWidth - 12)
expect(parseFloat(tooltip.style.top) + 56).toBeLessThanOrEqual(window.innerHeight - 12)
expect(tooltip.style.overflowWrap).toBe('anywhere')
})
it('keeps descriptions visible at the upper left edge', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
render(<NightPauseTooltip label="End of day 1 at 18:00" x={-50} y={-50} />)
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '12px', top: '12px' })
})
@@ -0,0 +1,24 @@
import { useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export default function NightPauseTooltip({ label, x, y }: { label: string; x: number; y: number }) {
const card = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const place = () => {
const node = card.current!
const { width, height } = node.getBoundingClientRect()
const left = x + 14 + width > window.innerWidth - 12 ? x - width - 14 : x + 14
node.style.left = `${Math.max(12, Math.min(left, window.innerWidth - width - 12))}px`
node.style.top = `${Math.max(12, Math.min(y - 10, window.innerHeight - height - 12))}px`
}
place()
window.addEventListener('resize', place)
return () => window.removeEventListener('resize', place)
}, [label, x, y])
return createPortal(
<div ref={card} role="tooltip" data-testid="tooltip"
className="pointer-events-none fixed z-[var(--z-toast)] rounded-lg border border-edge-faint bg-surface-card px-3 py-2 font-semibold leading-relaxed text-content shadow-popover"
style={{ left: x + 14, top: y - 10, maxWidth: 'min(16rem, calc(100vw - 24px))', overflowWrap: 'anywhere', fontFamily: 'var(--font-system)', fontSize: 12 }}
>{label}</div>, document.body,
)
}

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